@aeroswarm/weather 0.1.5 → 0.3.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 +66 -0
- package/dist/aftn.d.ts +4 -4
- package/dist/aftn.d.ts.map +1 -1
- package/dist/animation.d.ts +16 -0
- package/dist/animation.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 +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1207 -23
- 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/operations.d.ts +41 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/ops.d.ts +181 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.test.d.ts +2 -0
- package/dist/ops.test.d.ts.map +1 -0
- package/dist/runway.d.ts +44 -0
- package/dist/runway.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 +15 -2
- 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,
|
|
@@ -517,14 +1302,14 @@ function animationFromConditions(conditions) {
|
|
|
517
1302
|
thunderstorm
|
|
518
1303
|
};
|
|
519
1304
|
}
|
|
520
|
-
function
|
|
1305
|
+
function selectConditions(input) {
|
|
521
1306
|
const at = input.at ?? /* @__PURE__ */ new Date();
|
|
522
1307
|
const maxAge = input.maxObservationAgeMs ?? 3 * 36e5;
|
|
523
1308
|
const { observation, forecast } = input;
|
|
524
1309
|
const age = observation ? at.getTime() - observation.observedAt.getTime() : null;
|
|
525
1310
|
if (observation && age !== null && age >= 0 && age <= maxAge) {
|
|
526
1311
|
return {
|
|
527
|
-
|
|
1312
|
+
conditions: observation.conditions,
|
|
528
1313
|
source: observation.kind,
|
|
529
1314
|
station: observation.station,
|
|
530
1315
|
validAt: observation.observedAt,
|
|
@@ -536,7 +1321,7 @@ function animationSnapshot(input) {
|
|
|
536
1321
|
const predicted = forecast ? forecastAt(forecast, at, { includeProbable: input.includeProbable }) : null;
|
|
537
1322
|
if (forecast && predicted) {
|
|
538
1323
|
return {
|
|
539
|
-
|
|
1324
|
+
conditions: predicted.conditions,
|
|
540
1325
|
source: "TAF",
|
|
541
1326
|
station: forecast.station,
|
|
542
1327
|
validAt: at,
|
|
@@ -547,7 +1332,7 @@ function animationSnapshot(input) {
|
|
|
547
1332
|
}
|
|
548
1333
|
if (observation) {
|
|
549
1334
|
return {
|
|
550
|
-
|
|
1335
|
+
conditions: observation.conditions,
|
|
551
1336
|
source: observation.kind,
|
|
552
1337
|
station: observation.station,
|
|
553
1338
|
validAt: observation.observedAt,
|
|
@@ -558,6 +1343,12 @@ function animationSnapshot(input) {
|
|
|
558
1343
|
}
|
|
559
1344
|
return null;
|
|
560
1345
|
}
|
|
1346
|
+
function animationSnapshot(input) {
|
|
1347
|
+
const selected = selectConditions(input);
|
|
1348
|
+
if (!selected) return null;
|
|
1349
|
+
const { conditions, ...meta } = selected;
|
|
1350
|
+
return { state: animationFromConditions(conditions), ...meta };
|
|
1351
|
+
}
|
|
561
1352
|
|
|
562
1353
|
// src/grid.ts
|
|
563
1354
|
var KT_TO_MS = 0.514444;
|
|
@@ -1020,6 +1811,323 @@ function weatherGridFromOpenMeteo(response, lattice, options = {}) {
|
|
|
1020
1811
|
};
|
|
1021
1812
|
}
|
|
1022
1813
|
|
|
1814
|
+
// src/ops.ts
|
|
1815
|
+
var METERS_PER_MILE2 = 1609.344;
|
|
1816
|
+
var FAA_CATEGORY_THRESHOLDS = {
|
|
1817
|
+
lifrCeilingFt: 500,
|
|
1818
|
+
ifrCeilingFt: 1e3,
|
|
1819
|
+
mvfrCeilingFt: 3e3,
|
|
1820
|
+
lifrVisibilityM: 1 * METERS_PER_MILE2,
|
|
1821
|
+
ifrVisibilityM: 3 * METERS_PER_MILE2,
|
|
1822
|
+
mvfrVisibilityM: 5 * METERS_PER_MILE2
|
|
1823
|
+
};
|
|
1824
|
+
var RANK = { LIFR: 0, IFR: 1, MVFR: 2, VFR: 3 };
|
|
1825
|
+
var BY_RANK = ["LIFR", "IFR", "MVFR", "VFR"];
|
|
1826
|
+
function ceilingOf(clouds) {
|
|
1827
|
+
let lowest = null;
|
|
1828
|
+
let unknown = false;
|
|
1829
|
+
for (const layer of clouds) {
|
|
1830
|
+
if (layer.cover !== "BKN" && layer.cover !== "OVC" && layer.cover !== "VV") continue;
|
|
1831
|
+
if (layer.baseFt === null) {
|
|
1832
|
+
unknown = true;
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
lowest = lowest === null ? layer.baseFt : Math.min(lowest, layer.baseFt);
|
|
1836
|
+
}
|
|
1837
|
+
return { ceilingFt: lowest, unknown };
|
|
1838
|
+
}
|
|
1839
|
+
var rankByCeiling = (ceilingFt, t) => {
|
|
1840
|
+
if (ceilingFt === null) return RANK.VFR;
|
|
1841
|
+
if (ceilingFt < t.lifrCeilingFt) return RANK.LIFR;
|
|
1842
|
+
if (ceilingFt < t.ifrCeilingFt) return RANK.IFR;
|
|
1843
|
+
if (ceilingFt <= t.mvfrCeilingFt) return RANK.MVFR;
|
|
1844
|
+
return RANK.VFR;
|
|
1845
|
+
};
|
|
1846
|
+
var rankByVisibility = (visibilityM, t) => {
|
|
1847
|
+
if (visibilityM < t.lifrVisibilityM) return RANK.LIFR;
|
|
1848
|
+
if (visibilityM < t.ifrVisibilityM) return RANK.IFR;
|
|
1849
|
+
if (visibilityM <= t.mvfrVisibilityM) return RANK.MVFR;
|
|
1850
|
+
return RANK.VFR;
|
|
1851
|
+
};
|
|
1852
|
+
function flightCategory(conditions, thresholds = FAA_CATEGORY_THRESHOLDS) {
|
|
1853
|
+
if (conditions.cavok) {
|
|
1854
|
+
return { category: "VFR", ceilingFt: null, ceilingUnknown: false, visibilityM: 1e4, limitedBy: null };
|
|
1855
|
+
}
|
|
1856
|
+
const { ceilingFt, unknown } = ceilingOf(conditions.clouds);
|
|
1857
|
+
const visibilityM = conditions.visibilityM;
|
|
1858
|
+
const ceilingRank = unknown && ceilingFt === null ? null : rankByCeiling(ceilingFt, thresholds);
|
|
1859
|
+
const visibilityRank = visibilityM === null ? null : rankByVisibility(visibilityM, thresholds);
|
|
1860
|
+
if (ceilingRank === null && visibilityRank === null) {
|
|
1861
|
+
return { category: null, ceilingFt, ceilingUnknown: unknown, visibilityM, limitedBy: null };
|
|
1862
|
+
}
|
|
1863
|
+
const rank = Math.min(ceilingRank ?? Number.POSITIVE_INFINITY, visibilityRank ?? Number.POSITIVE_INFINITY);
|
|
1864
|
+
const limitedBy = ceilingRank !== null && visibilityRank !== null ? ceilingRank === visibilityRank ? "both" : ceilingRank < visibilityRank ? "ceiling" : "visibility" : ceilingRank !== null ? "ceiling" : "visibility";
|
|
1865
|
+
return {
|
|
1866
|
+
category: BY_RANK[rank] ?? null,
|
|
1867
|
+
ceilingFt,
|
|
1868
|
+
ceilingUnknown: unknown,
|
|
1869
|
+
visibilityM,
|
|
1870
|
+
limitedBy
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
var round1 = (value) => Math.round(value * 10) / 10;
|
|
1874
|
+
function windComponents(wind, runwayHeadingTrueDeg) {
|
|
1875
|
+
if (wind.directionDeg === null) {
|
|
1876
|
+
return { headwindKt: null, tailwindKt: null, crosswindKt: round1(wind.speedKt), crosswindFrom: null, variable: true };
|
|
1877
|
+
}
|
|
1878
|
+
const angle = (wind.directionDeg - runwayHeadingTrueDeg) * Math.PI / 180;
|
|
1879
|
+
const head = wind.speedKt * Math.cos(angle);
|
|
1880
|
+
const cross = wind.speedKt * Math.sin(angle);
|
|
1881
|
+
return {
|
|
1882
|
+
headwindKt: round1(Math.max(0, head)),
|
|
1883
|
+
tailwindKt: round1(Math.max(0, -head)),
|
|
1884
|
+
crosswindKt: round1(Math.abs(cross)),
|
|
1885
|
+
crosswindFrom: Math.abs(cross) < 0.05 ? null : cross > 0 ? "right" : "left",
|
|
1886
|
+
variable: false
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
function runwayAdvice(wind, runways, limits = {}) {
|
|
1890
|
+
const advise = (direction) => {
|
|
1891
|
+
const steady = windComponents(wind, direction.headingTrueDeg);
|
|
1892
|
+
const gust = wind.gustKt !== null && wind.gustKt !== void 0 ? windComponents({ directionDeg: wind.directionDeg, speedKt: wind.gustKt }, direction.headingTrueDeg) : void 0;
|
|
1893
|
+
const worst = gust ?? steady;
|
|
1894
|
+
return {
|
|
1895
|
+
direction,
|
|
1896
|
+
steady,
|
|
1897
|
+
...gust ? { gust } : {},
|
|
1898
|
+
exceeds: {
|
|
1899
|
+
crosswind: limits.crosswindKt === void 0 ? null : worst.crosswindKt > limits.crosswindKt,
|
|
1900
|
+
tailwind: limits.tailwindKt === void 0 ? null : worst.tailwindKt === null ? null : worst.tailwindKt > limits.tailwindKt
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
};
|
|
1904
|
+
return runways.map((runway) => {
|
|
1905
|
+
const first = advise(runway.directions[0]);
|
|
1906
|
+
const second = advise(runway.directions[1]);
|
|
1907
|
+
const headA = first.steady.headwindKt ?? Number.NEGATIVE_INFINITY;
|
|
1908
|
+
const headB = second.steady.headwindKt ?? Number.NEGATIVE_INFINITY;
|
|
1909
|
+
const preferred = headA === headB ? first.steady.crosswindKt <= second.steady.crosswindKt ? first : second : headA > headB ? first : second;
|
|
1910
|
+
return { runway, directions: [first, second], preferred };
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
function rvrAgainst(rvr, minimum) {
|
|
1914
|
+
const value = rvr.variableMinM ?? rvr.meters;
|
|
1915
|
+
if (rvr.modifier === "above") return value >= minimum ? "above" : "unknown";
|
|
1916
|
+
if (rvr.modifier === "below") return value <= minimum ? "below" : "unknown";
|
|
1917
|
+
return value < minimum ? "below" : "above";
|
|
1918
|
+
}
|
|
1919
|
+
function evaluateMinima(conditions, minima) {
|
|
1920
|
+
const reasons = [];
|
|
1921
|
+
const unknown = [];
|
|
1922
|
+
if (minima.visibilityM !== void 0) {
|
|
1923
|
+
const visibility = conditions.cavok ? 1e4 : conditions.visibilityM;
|
|
1924
|
+
if (visibility === null) unknown.push("visibility");
|
|
1925
|
+
else if (visibility < minima.visibilityM) {
|
|
1926
|
+
reasons.push({ kind: "visibility", value: visibility, minimum: minima.visibilityM });
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (minima.ceilingFt !== void 0 && !conditions.cavok) {
|
|
1930
|
+
const { ceilingFt, unknown: ceilingUnknown } = ceilingOf(conditions.clouds);
|
|
1931
|
+
if (ceilingFt !== null && ceilingFt < minima.ceilingFt) {
|
|
1932
|
+
reasons.push({ kind: "ceiling", value: ceilingFt, minimum: minima.ceilingFt });
|
|
1933
|
+
} else if (ceilingUnknown && ceilingFt === null) unknown.push("ceiling");
|
|
1934
|
+
}
|
|
1935
|
+
if (minima.rvrM !== void 0) {
|
|
1936
|
+
const wanted = minima.rvrRunways;
|
|
1937
|
+
const reports = (conditions.rvr ?? []).filter((r) => !wanted || wanted.includes(r.runway));
|
|
1938
|
+
if (reports.length === 0) unknown.push("rvr");
|
|
1939
|
+
for (const rvr of reports) {
|
|
1940
|
+
const verdict = rvrAgainst(rvr, minima.rvrM);
|
|
1941
|
+
if (verdict === "below") {
|
|
1942
|
+
reasons.push({ kind: "rvr", value: rvr.variableMinM ?? rvr.meters, minimum: minima.rvrM, runway: rvr.runway });
|
|
1943
|
+
} else if (verdict === "unknown" && !unknown.includes("rvr")) unknown.push("rvr");
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
return { below: reasons.length > 0, reasons, unknown };
|
|
1947
|
+
}
|
|
1948
|
+
var PRECIPITATION2 = /* @__PURE__ */ new Set(["DZ", "RA", "SN", "SG", "IC", "PL", "GR", "GS", "UP"]);
|
|
1949
|
+
var hasThunderstorm = (c) => c.weather.some((w) => w.descriptor === "TS" && w.intensity !== "vicinity") || c.clouds.some((layer) => layer.type === "CB");
|
|
1950
|
+
var hasHeavyPrecipitation = (weather) => weather.some((w) => w.intensity === "heavy" && w.phenomena.some((p) => PRECIPITATION2.has(p)));
|
|
1951
|
+
function sourceAt(taf, time, includeProbable) {
|
|
1952
|
+
const covers = (p) => time >= p.from.getTime() && time < p.to.getTime();
|
|
1953
|
+
if (taf.periods.some((p) => p.change === "TEMPO" && covers(p) && (p.probability === void 0 || includeProbable))) {
|
|
1954
|
+
return "TEMPO";
|
|
1955
|
+
}
|
|
1956
|
+
if (includeProbable && taf.periods.some((p) => p.change === "PROB" && covers(p))) return "PROB";
|
|
1957
|
+
let latest = null;
|
|
1958
|
+
for (const p of taf.periods) {
|
|
1959
|
+
if ((p.change === "FM" || p.change === "BECMG") && p.from.getTime() <= time) {
|
|
1960
|
+
if (!latest || p.from.getTime() >= latest.from.getTime()) latest = p;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
return latest ? latest.change : "BASE";
|
|
1964
|
+
}
|
|
1965
|
+
function upcomingChanges(taf, from, hours, options = {}) {
|
|
1966
|
+
if (taf.cancelled) return [];
|
|
1967
|
+
const thresholds = options.thresholds ?? FAA_CATEGORY_THRESHOLDS;
|
|
1968
|
+
const includeProbable = options.includeProbable === true;
|
|
1969
|
+
const step = (options.stepMinutes ?? 30) * 6e4;
|
|
1970
|
+
const start = Math.max(from.getTime(), taf.validFrom.getTime());
|
|
1971
|
+
const end = Math.min(from.getTime() + hours * 36e5, taf.validTo.getTime());
|
|
1972
|
+
if (start >= end) return [];
|
|
1973
|
+
const times = /* @__PURE__ */ new Set();
|
|
1974
|
+
for (let t = start; t <= end; t += step) times.add(t);
|
|
1975
|
+
for (const p of taf.periods) {
|
|
1976
|
+
for (const edge of [p.from.getTime(), p.to.getTime()]) if (edge > start && edge < end) times.add(edge);
|
|
1977
|
+
}
|
|
1978
|
+
const events = [];
|
|
1979
|
+
let previous = null;
|
|
1980
|
+
let windBaseline = null;
|
|
1981
|
+
for (const time of [...times].sort((a, b) => a - b)) {
|
|
1982
|
+
const at = forecastAt(taf, new Date(time), { includeProbable });
|
|
1983
|
+
if (!at) continue;
|
|
1984
|
+
const c = at.conditions;
|
|
1985
|
+
const category = flightCategory(c, thresholds).category;
|
|
1986
|
+
const thunder = hasThunderstorm(c);
|
|
1987
|
+
const heavy = hasHeavyPrecipitation(c.weather);
|
|
1988
|
+
const lowVis = options.lowVisibilityM !== void 0 && c.visibilityM !== null && c.visibilityM < options.lowVisibilityM;
|
|
1989
|
+
const source = sourceAt(taf, time, includeProbable);
|
|
1990
|
+
const when = new Date(time);
|
|
1991
|
+
if (!previous) {
|
|
1992
|
+
if (thunder) {
|
|
1993
|
+
events.push({ kind: "THUNDERSTORM_START", at: when, source, ongoing: true, detail: "Badai petir/CB sudah berlangsung" });
|
|
1994
|
+
}
|
|
1995
|
+
windBaseline = c.wind.speedKt;
|
|
1996
|
+
} else {
|
|
1997
|
+
if (category !== previous.category && category !== null) {
|
|
1998
|
+
events.push({
|
|
1999
|
+
kind: "CATEGORY_CHANGE",
|
|
2000
|
+
at: when,
|
|
2001
|
+
source,
|
|
2002
|
+
from: previous.category,
|
|
2003
|
+
to: category,
|
|
2004
|
+
detail: `Kategori ${previous.category ?? "?"} menjadi ${category}`
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
if (thunder && !previous.thunder) events.push({ kind: "THUNDERSTORM_START", at: when, source, detail: "Badai petir/CB mulai" });
|
|
2008
|
+
if (!thunder && previous.thunder) events.push({ kind: "THUNDERSTORM_END", at: when, source, detail: "Badai petir/CB berakhir" });
|
|
2009
|
+
if (heavy && !previous.heavy) events.push({ kind: "HEAVY_PRECIPITATION_START", at: when, source, detail: "Hujan atau presipitasi lebat" });
|
|
2010
|
+
if (lowVis && !previous.lowVis) {
|
|
2011
|
+
events.push({ kind: "LOW_VISIBILITY_START", at: when, source, detail: `Jarak pandang di bawah ${options.lowVisibilityM} m` });
|
|
2012
|
+
}
|
|
2013
|
+
if (options.windChangeKt !== void 0 && windBaseline !== null && Math.abs(c.wind.speedKt - windBaseline) >= options.windChangeKt) {
|
|
2014
|
+
events.push({
|
|
2015
|
+
kind: "WIND_CHANGE",
|
|
2016
|
+
at: when,
|
|
2017
|
+
source,
|
|
2018
|
+
detail: `Angin ${windBaseline} menjadi ${c.wind.speedKt} kt`
|
|
2019
|
+
});
|
|
2020
|
+
windBaseline = c.wind.speedKt;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
previous = { category, thunder, heavy, lowVis };
|
|
2024
|
+
}
|
|
2025
|
+
return events;
|
|
2026
|
+
}
|
|
2027
|
+
function qnhTrend(previous, current, options = {}) {
|
|
2028
|
+
if (previous.qnhHpa === null || current.qnhHpa === null) return null;
|
|
2029
|
+
const hours = (current.observedAt.getTime() - previous.observedAt.getTime()) / 36e5;
|
|
2030
|
+
if (hours <= 0) return null;
|
|
2031
|
+
const delta = current.qnhHpa - previous.qnhHpa;
|
|
2032
|
+
const steady = Math.abs(delta) < (options.steadyHpa ?? 0.5);
|
|
2033
|
+
const perHour = delta / hours;
|
|
2034
|
+
return {
|
|
2035
|
+
deltaHpa: delta,
|
|
2036
|
+
hours,
|
|
2037
|
+
hpaPerHour: Math.round(perHour * 100) / 100,
|
|
2038
|
+
direction: steady ? "steady" : delta > 0 ? "rising" : "falling",
|
|
2039
|
+
rapid: options.rapidHpaPerHour === void 0 ? null : Math.abs(perHour) >= options.rapidHpaPerHour
|
|
2040
|
+
};
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/operations.ts
|
|
2044
|
+
function stationOperations(store, station, at, config = {}) {
|
|
2045
|
+
const selected = store.conditionsAt(station, at, { maxObservationAgeMs: config.maxObservationAgeMs });
|
|
2046
|
+
if (!selected) return null;
|
|
2047
|
+
const { conditions } = selected;
|
|
2048
|
+
const taf = store.forecastFor(station, at);
|
|
2049
|
+
const upcoming = taf ? upcomingChanges(taf, at, config.horizonHours ?? 6, { ...config.upcoming, thresholds: config.thresholds }) : [];
|
|
2050
|
+
const [latest, previous] = store.observationsUntil(station, at, 2);
|
|
2051
|
+
return {
|
|
2052
|
+
station,
|
|
2053
|
+
at,
|
|
2054
|
+
basis: {
|
|
2055
|
+
source: selected.source,
|
|
2056
|
+
validAt: selected.validAt,
|
|
2057
|
+
stale: selected.stale,
|
|
2058
|
+
temporary: selected.temporary,
|
|
2059
|
+
transition: selected.transition
|
|
2060
|
+
},
|
|
2061
|
+
category: flightCategory(conditions, config.thresholds),
|
|
2062
|
+
...config.runways ? { runways: runwayAdvice(conditions.wind, config.runways, config.limits) } : {},
|
|
2063
|
+
...config.minima ? { minima: evaluateMinima(conditions, config.minima) } : {},
|
|
2064
|
+
upcoming,
|
|
2065
|
+
qnh: latest && previous ? qnhTrend(previous, latest, config.qnh) : null
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// src/runway.ts
|
|
2070
|
+
var normalize = (degrees) => (degrees % 360 + 360) % 360;
|
|
2071
|
+
function magneticToTrue(magneticDeg, variationEastDeg) {
|
|
2072
|
+
return normalize(magneticDeg + variationEastDeg);
|
|
2073
|
+
}
|
|
2074
|
+
var reciprocal = (headingDeg) => normalize(headingDeg + 180);
|
|
2075
|
+
function reciprocalDesignator(designator) {
|
|
2076
|
+
const match = designator.trim().toUpperCase().match(/^(\d{2})([LRC])?$/);
|
|
2077
|
+
if (!match) return null;
|
|
2078
|
+
const number = Number(match[1]);
|
|
2079
|
+
if (number < 1 || number > 36) return null;
|
|
2080
|
+
const opposite = (number + 18 - 1) % 36 + 1;
|
|
2081
|
+
const side = match[2] === "L" ? "R" : match[2] === "R" ? "L" : match[2] ?? "";
|
|
2082
|
+
return `${String(opposite).padStart(2, "0")}${side}`;
|
|
2083
|
+
}
|
|
2084
|
+
function designatorHeadingDeg(designator) {
|
|
2085
|
+
const match = designator.trim().match(/^(\d{2})[LRC]?$/);
|
|
2086
|
+
return match ? Number(match[1]) * 10 : null;
|
|
2087
|
+
}
|
|
2088
|
+
function runwayFromHeading(designator, magneticHeadingDeg, variationEastDeg = 0) {
|
|
2089
|
+
const opposite = reciprocalDesignator(designator);
|
|
2090
|
+
const magnetic = magneticHeadingDeg ?? designatorHeadingDeg(designator);
|
|
2091
|
+
if (!opposite || magnetic === null) return null;
|
|
2092
|
+
const heading = magneticToTrue(magnetic, variationEastDeg);
|
|
2093
|
+
const [low, high] = [designator.toUpperCase(), opposite].sort();
|
|
2094
|
+
const first = { designator: designator.toUpperCase(), headingTrueDeg: heading };
|
|
2095
|
+
const second = { designator: opposite, headingTrueDeg: reciprocal(heading) };
|
|
2096
|
+
return {
|
|
2097
|
+
name: `${low}/${high}`,
|
|
2098
|
+
directions: [first, second]
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
function headingFromPolygon(points) {
|
|
2102
|
+
if (points.length < 2) return null;
|
|
2103
|
+
const meanLat = points.reduce((sum, p) => sum + p.lat, 0) / points.length;
|
|
2104
|
+
const meanLon = points.reduce((sum, p) => sum + p.lon, 0) / points.length;
|
|
2105
|
+
const kx = Math.cos(meanLat * Math.PI / 180);
|
|
2106
|
+
let sxx = 0;
|
|
2107
|
+
let syy = 0;
|
|
2108
|
+
let sxy = 0;
|
|
2109
|
+
for (const p of points) {
|
|
2110
|
+
const x = (p.lon - meanLon) * kx;
|
|
2111
|
+
const y = p.lat - meanLat;
|
|
2112
|
+
sxx += x * x;
|
|
2113
|
+
syy += y * y;
|
|
2114
|
+
sxy += x * y;
|
|
2115
|
+
}
|
|
2116
|
+
if (sxx + syy === 0) return null;
|
|
2117
|
+
const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy);
|
|
2118
|
+
const heading = Math.atan2(Math.cos(theta), Math.sin(theta)) * 180 / Math.PI;
|
|
2119
|
+
return normalize(heading) % 180;
|
|
2120
|
+
}
|
|
2121
|
+
function runwayFromPolygon(points, variationEastDeg = 0) {
|
|
2122
|
+
const trueHeading = headingFromPolygon(points);
|
|
2123
|
+
if (trueHeading === null) return null;
|
|
2124
|
+
const magnetic = normalize(trueHeading - variationEastDeg);
|
|
2125
|
+
const number = Math.round(magnetic / 10) % 36 || 36;
|
|
2126
|
+
const designator = String(number).padStart(2, "0");
|
|
2127
|
+
const spec = runwayFromHeading(designator, magnetic, variationEastDeg);
|
|
2128
|
+
return spec;
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1023
2131
|
// src/store.ts
|
|
1024
2132
|
var MAX_OBSERVATIONS_PER_STATION = 48;
|
|
1025
2133
|
var MAX_FORECASTS_PER_STATION = 8;
|
|
@@ -1039,9 +2147,17 @@ var WeatherStore = class {
|
|
|
1039
2147
|
ingest(message, options = {}) {
|
|
1040
2148
|
const receivedAt = options.receivedAt ?? /* @__PURE__ */ new Date();
|
|
1041
2149
|
const manual = options.manual === true;
|
|
1042
|
-
const result = { metars: 0, tafs: 0, skipped: 0, stations: [] };
|
|
2150
|
+
const result = { metars: 0, tafs: 0, nil: 0, other: 0, skipped: 0, stations: [] };
|
|
1043
2151
|
const touched = /* @__PURE__ */ new Set();
|
|
1044
2152
|
for (const raw of extractReports(message)) {
|
|
2153
|
+
if (raw.kind !== "METAR" && raw.kind !== "SPECI" && raw.kind !== "TAF") {
|
|
2154
|
+
result.other++;
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
if (parseNil(raw.text, receivedAt)) {
|
|
2158
|
+
result.nil++;
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
1045
2161
|
if (raw.kind === "TAF") {
|
|
1046
2162
|
const taf = parseTaf(raw.text, receivedAt);
|
|
1047
2163
|
if (!taf) {
|
|
@@ -1110,6 +2226,25 @@ var WeatherStore = class {
|
|
|
1110
2226
|
includeProbable: options.includeProbable
|
|
1111
2227
|
});
|
|
1112
2228
|
}
|
|
2229
|
+
/** Kondisi (belum diubah menjadi parameter animasi) di satu stasiun pada waktu `at`. */
|
|
2230
|
+
conditionsAt(station, at, options = {}) {
|
|
2231
|
+
return selectConditions({
|
|
2232
|
+
observation: this.pickObservation(station, at, options.maxObservationAgeMs),
|
|
2233
|
+
forecast: this.pickForecast(station, at),
|
|
2234
|
+
at,
|
|
2235
|
+
maxObservationAgeMs: options.maxObservationAgeMs,
|
|
2236
|
+
includeProbable: options.includeProbable
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2239
|
+
/** TAF yang berlaku di satu stasiun pada waktu `at` (manual didahulukan), atau null. */
|
|
2240
|
+
forecastFor(station, at) {
|
|
2241
|
+
return this.pickForecast(station, at);
|
|
2242
|
+
}
|
|
2243
|
+
/** Pengamatan terakhir (paling baru dulu) di satu stasiun sampai waktu `at`, paling banyak `count`. */
|
|
2244
|
+
observationsUntil(station, at, count) {
|
|
2245
|
+
const list = this.observations.get(station) ?? [];
|
|
2246
|
+
return list.map((s) => s.report).filter((m) => m.observedAt.getTime() <= at.getTime()).sort((x, y) => y.observedAt.getTime() - x.observedAt.getTime()).slice(0, count);
|
|
2247
|
+
}
|
|
1113
2248
|
/** Rentang waktu dan pembagian periode (BASE/BECMG/TEMPO/FM) dari TAF, untuk timeline. */
|
|
1114
2249
|
timeline() {
|
|
1115
2250
|
const segments = [];
|
|
@@ -1254,12 +2389,34 @@ var WeatherStore = class {
|
|
|
1254
2389
|
function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
|
|
1255
2390
|
const reports = [];
|
|
1256
2391
|
for (const raw of extractReports(message)) {
|
|
1257
|
-
|
|
2392
|
+
let report;
|
|
2393
|
+
switch (raw.kind) {
|
|
2394
|
+
case "TAF":
|
|
2395
|
+
report = parseNil(raw.text, reference) ?? parseTaf(raw.text, reference);
|
|
2396
|
+
break;
|
|
2397
|
+
case "METAR":
|
|
2398
|
+
case "SPECI":
|
|
2399
|
+
report = parseNil(raw.text, reference) ?? parseMetar(raw.text, reference);
|
|
2400
|
+
break;
|
|
2401
|
+
case "SIGMET":
|
|
2402
|
+
case "AIRMET":
|
|
2403
|
+
report = parseSigmet(raw.text, reference);
|
|
2404
|
+
break;
|
|
2405
|
+
case "VAA":
|
|
2406
|
+
report = parseVaa(raw.text, reference);
|
|
2407
|
+
break;
|
|
2408
|
+
case "SNOWTAM":
|
|
2409
|
+
report = parseSnowtam(raw.text, reference);
|
|
2410
|
+
break;
|
|
2411
|
+
}
|
|
1258
2412
|
if (report) reports.push(report);
|
|
1259
2413
|
}
|
|
1260
2414
|
return reports;
|
|
1261
2415
|
}
|
|
1262
2416
|
export {
|
|
2417
|
+
ALTITUDE_PATTERNS,
|
|
2418
|
+
FAA_CATEGORY_THRESHOLDS,
|
|
2419
|
+
POINT,
|
|
1263
2420
|
WEATHER_GRID_STANDARD,
|
|
1264
2421
|
WEATHER_GRID_VERSION,
|
|
1265
2422
|
WeatherStore,
|
|
@@ -1267,14 +2424,24 @@ export {
|
|
|
1267
2424
|
animationSnapshot,
|
|
1268
2425
|
blendConditions,
|
|
1269
2426
|
blendWindGrids,
|
|
2427
|
+
ceilingOf,
|
|
2428
|
+
compassDegrees,
|
|
1270
2429
|
decodeGridJson,
|
|
1271
2430
|
decodeWeatherMessage,
|
|
2431
|
+
designatorHeadingDeg,
|
|
1272
2432
|
directionFromUV,
|
|
1273
2433
|
emptyConditions,
|
|
2434
|
+
evaluateMinima,
|
|
1274
2435
|
extractReports,
|
|
2436
|
+
findAltitude,
|
|
2437
|
+
findMovement,
|
|
2438
|
+
flightCategory,
|
|
1275
2439
|
forecastAt,
|
|
2440
|
+
headingFromPolygon,
|
|
1276
2441
|
interpolateScalar,
|
|
2442
|
+
isCompass,
|
|
1277
2443
|
latticePoints,
|
|
2444
|
+
magneticToTrue,
|
|
1278
2445
|
mergeConditions,
|
|
1279
2446
|
nudgeScalar,
|
|
1280
2447
|
nudgeWind,
|
|
@@ -1282,19 +2449,36 @@ export {
|
|
|
1282
2449
|
parseGrib2Json,
|
|
1283
2450
|
parseGridJson,
|
|
1284
2451
|
parseMetar,
|
|
2452
|
+
parseNil,
|
|
2453
|
+
parsePosition,
|
|
2454
|
+
parsePositions,
|
|
2455
|
+
parseSigmet,
|
|
2456
|
+
parseSnowtam,
|
|
1285
2457
|
parseTaf,
|
|
2458
|
+
parseVaCloud,
|
|
2459
|
+
parseVaa,
|
|
1286
2460
|
parseWeatherGridSeries,
|
|
1287
2461
|
pointOf,
|
|
2462
|
+
qnhTrend,
|
|
2463
|
+
reciprocal,
|
|
2464
|
+
reciprocalDesignator,
|
|
1288
2465
|
resolveDayTime,
|
|
2466
|
+
runwayAdvice,
|
|
2467
|
+
runwayFromHeading,
|
|
2468
|
+
runwayFromPolygon,
|
|
1289
2469
|
sampleScalar,
|
|
1290
2470
|
sampleWind,
|
|
2471
|
+
selectConditions,
|
|
1291
2472
|
serializeWeatherGridSeries,
|
|
1292
2473
|
seriesFieldsAt,
|
|
1293
2474
|
seriesRange,
|
|
1294
2475
|
speedOf,
|
|
2476
|
+
stationOperations,
|
|
1295
2477
|
stationWindsFromMetars,
|
|
1296
2478
|
stationWindsFromModelPoints,
|
|
2479
|
+
upcomingChanges,
|
|
1297
2480
|
uvFromSpeedDirection,
|
|
1298
2481
|
weatherGridFromOpenMeteo,
|
|
2482
|
+
windComponents,
|
|
1299
2483
|
windGridFromStations
|
|
1300
2484
|
};
|