@aeroswarm/weather 0.1.4 → 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/grid.d.ts +12 -0
- package/dist/grid.d.ts.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +925 -36
- 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 +25 -1
- 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,
|
|
@@ -643,6 +1428,52 @@ function sampleWind(grid, lon, lat) {
|
|
|
643
1428
|
const v = bilinear(grid.v, grid.bounds, grid.nx, grid.ny, lon, lat);
|
|
644
1429
|
return u === null || v === null ? null : { u, v };
|
|
645
1430
|
}
|
|
1431
|
+
var influence = (distance, radius) => distance >= radius ? 0 : (radius * radius - distance * distance) / (radius * radius + distance * distance);
|
|
1432
|
+
function nudgeValues(values, bounds, nx, ny, deltas, radius) {
|
|
1433
|
+
const out = Float32Array.from(values);
|
|
1434
|
+
if (deltas.length === 0) return out;
|
|
1435
|
+
for (let row = 0; row < ny; row++) {
|
|
1436
|
+
for (let col = 0; col < nx; col++) {
|
|
1437
|
+
const index = row * nx + col;
|
|
1438
|
+
if (Number.isNaN(out[index])) continue;
|
|
1439
|
+
const cell = pointOf(bounds, nx, ny, col, row);
|
|
1440
|
+
let weights = 0;
|
|
1441
|
+
let weighted = 0;
|
|
1442
|
+
for (const d of deltas) {
|
|
1443
|
+
const w = influence(distanceKm(cell, d), radius);
|
|
1444
|
+
weights += w;
|
|
1445
|
+
weighted += w * d.delta;
|
|
1446
|
+
}
|
|
1447
|
+
if (weights > 0) out[index] = out[index] + weighted / weights * Math.min(1, weights);
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
return out;
|
|
1451
|
+
}
|
|
1452
|
+
function nudgeScalar(grid, points, options = {}) {
|
|
1453
|
+
const deltas = [];
|
|
1454
|
+
for (const p of points) {
|
|
1455
|
+
const model = sampleScalar(grid, p.lon, p.lat);
|
|
1456
|
+
if (model !== null) deltas.push({ lat: p.lat, lon: p.lon, delta: p.value - model });
|
|
1457
|
+
}
|
|
1458
|
+
return { ...grid, values: nudgeValues(grid.values, grid.bounds, grid.nx, grid.ny, deltas, options.radiusKm ?? 80) };
|
|
1459
|
+
}
|
|
1460
|
+
function nudgeWind(grid, stations, options = {}) {
|
|
1461
|
+
const du = [];
|
|
1462
|
+
const dv = [];
|
|
1463
|
+
for (const s of stations) {
|
|
1464
|
+
const model = sampleWind(grid, s.lon, s.lat);
|
|
1465
|
+
if (!model) continue;
|
|
1466
|
+
const obs = s.directionDeg === null ? { u: 0, v: 0 } : uvFromSpeedDirection(s.speedKt * KT_TO_MS, s.directionDeg);
|
|
1467
|
+
du.push({ lat: s.lat, lon: s.lon, delta: obs.u - model.u });
|
|
1468
|
+
dv.push({ lat: s.lat, lon: s.lon, delta: obs.v - model.v });
|
|
1469
|
+
}
|
|
1470
|
+
const radius = options.radiusKm ?? 80;
|
|
1471
|
+
return {
|
|
1472
|
+
...grid,
|
|
1473
|
+
u: nudgeValues(grid.u, grid.bounds, grid.nx, grid.ny, du, radius),
|
|
1474
|
+
v: nudgeValues(grid.v, grid.bounds, grid.nx, grid.ny, dv, radius)
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
646
1477
|
function blendWindGrids(a, b, t) {
|
|
647
1478
|
if (a.nx !== b.nx || a.ny !== b.ny) throw new Error("Grid angin harus berukuran sama");
|
|
648
1479
|
const mix = (x, y) => Float32Array.from(x, (value, i) => value + (y[i] - value) * t);
|
|
@@ -993,9 +1824,17 @@ var WeatherStore = class {
|
|
|
993
1824
|
ingest(message, options = {}) {
|
|
994
1825
|
const receivedAt = options.receivedAt ?? /* @__PURE__ */ new Date();
|
|
995
1826
|
const manual = options.manual === true;
|
|
996
|
-
const result = { metars: 0, tafs: 0, skipped: 0, stations: [] };
|
|
1827
|
+
const result = { metars: 0, tafs: 0, nil: 0, other: 0, skipped: 0, stations: [] };
|
|
997
1828
|
const touched = /* @__PURE__ */ new Set();
|
|
998
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
|
+
}
|
|
999
1838
|
if (raw.kind === "TAF") {
|
|
1000
1839
|
const taf = parseTaf(raw.text, receivedAt);
|
|
1001
1840
|
if (!taf) {
|
|
@@ -1099,24 +1938,48 @@ var WeatherStore = class {
|
|
|
1099
1938
|
* koordinat, diinterpolasi ke grid. Mengembalikan null bila tidak ada stasiun yang bisa dipakai.
|
|
1100
1939
|
*/
|
|
1101
1940
|
fieldsAt(at, grid, options = {}) {
|
|
1941
|
+
const points = this.pointsAt(at, options);
|
|
1942
|
+
if (points.stations.length === 0) return null;
|
|
1943
|
+
return {
|
|
1944
|
+
at,
|
|
1945
|
+
stations: points.stations,
|
|
1946
|
+
wind: windGridFromStations(points.winds, grid),
|
|
1947
|
+
cloud: interpolateScalar(points.clouds, grid),
|
|
1948
|
+
rain: interpolateScalar(points.rains, grid),
|
|
1949
|
+
temperature: points.temperatures.length > 0 ? interpolateScalar(points.temperatures, grid) : null
|
|
1950
|
+
};
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Gabungkan medan model dengan laporan: grid model disesuaikan ke pengamatan (METAR/SPECI) atau
|
|
1954
|
+
* prakiraan (TAF) di stasiun yang punya koordinat, dalam jarak `radiusKm` dari tiap stasiun. Tanpa
|
|
1955
|
+
* stasiun yang bisa dipakai, medan model dikembalikan apa adanya.
|
|
1956
|
+
*/
|
|
1957
|
+
blendInto(model, modelName, at, options = {}) {
|
|
1958
|
+
const points = this.pointsAt(at, options);
|
|
1959
|
+
return {
|
|
1960
|
+
at,
|
|
1961
|
+
stations: [modelName, ...points.stations],
|
|
1962
|
+
wind: model.wind ? nudgeWind(model.wind, points.winds, options) : null,
|
|
1963
|
+
cloud: model.cloud ? nudgeScalar(model.cloud, points.clouds, options) : null,
|
|
1964
|
+
rain: model.rain ? nudgeScalar(model.rain, points.rains, options) : null,
|
|
1965
|
+
temperature: model.temperature ? nudgeScalar(model.temperature, points.temperatures, options) : null
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
/** Nilai tiap stasiun yang punya koordinat dan data pada waktu `at`. */
|
|
1969
|
+
pointsAt(at, options) {
|
|
1102
1970
|
const winds = [];
|
|
1103
1971
|
const clouds = [];
|
|
1104
1972
|
const rains = [];
|
|
1105
1973
|
const temperatures = [];
|
|
1106
|
-
const
|
|
1974
|
+
const stations = [];
|
|
1107
1975
|
for (const station of this.stations()) {
|
|
1108
1976
|
const place = this.locate(station);
|
|
1109
1977
|
if (!place) continue;
|
|
1110
1978
|
const snapshot = this.snapshotAt(station, at, options);
|
|
1111
1979
|
if (!snapshot) continue;
|
|
1112
|
-
|
|
1980
|
+
stations.push(station);
|
|
1113
1981
|
const { state } = snapshot;
|
|
1114
|
-
winds.push({
|
|
1115
|
-
lat: place.lat,
|
|
1116
|
-
lon: place.lon,
|
|
1117
|
-
speedKt: state.wind.speedKt,
|
|
1118
|
-
directionDeg: state.wind.fromDeg
|
|
1119
|
-
});
|
|
1982
|
+
winds.push({ lat: place.lat, lon: place.lon, speedKt: state.wind.speedKt, directionDeg: state.wind.fromDeg });
|
|
1120
1983
|
clouds.push({ lat: place.lat, lon: place.lon, value: state.clouds.total * 100 });
|
|
1121
1984
|
rains.push({
|
|
1122
1985
|
lat: place.lat,
|
|
@@ -1128,15 +1991,7 @@ var WeatherStore = class {
|
|
|
1128
1991
|
temperatures.push({ lat: place.lat, lon: place.lon, value: observed.temperatureC });
|
|
1129
1992
|
}
|
|
1130
1993
|
}
|
|
1131
|
-
|
|
1132
|
-
return {
|
|
1133
|
-
at,
|
|
1134
|
-
stations: used,
|
|
1135
|
-
wind: windGridFromStations(winds, grid),
|
|
1136
|
-
cloud: interpolateScalar(clouds, grid),
|
|
1137
|
-
rain: interpolateScalar(rains, grid),
|
|
1138
|
-
temperature: temperatures.length > 0 ? interpolateScalar(temperatures, grid) : null
|
|
1139
|
-
};
|
|
1994
|
+
return { stations, winds, clouds, rains, temperatures };
|
|
1140
1995
|
}
|
|
1141
1996
|
// ---- internal -------------------------------------------------------------
|
|
1142
1997
|
changed() {
|
|
@@ -1192,12 +2047,33 @@ var WeatherStore = class {
|
|
|
1192
2047
|
function decodeWeatherMessage(message, reference = /* @__PURE__ */ new Date()) {
|
|
1193
2048
|
const reports = [];
|
|
1194
2049
|
for (const raw of extractReports(message)) {
|
|
1195
|
-
|
|
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
|
+
}
|
|
1196
2070
|
if (report) reports.push(report);
|
|
1197
2071
|
}
|
|
1198
2072
|
return reports;
|
|
1199
2073
|
}
|
|
1200
2074
|
export {
|
|
2075
|
+
ALTITUDE_PATTERNS,
|
|
2076
|
+
POINT,
|
|
1201
2077
|
WEATHER_GRID_STANDARD,
|
|
1202
2078
|
WEATHER_GRID_VERSION,
|
|
1203
2079
|
WeatherStore,
|
|
@@ -1205,20 +2081,33 @@ export {
|
|
|
1205
2081
|
animationSnapshot,
|
|
1206
2082
|
blendConditions,
|
|
1207
2083
|
blendWindGrids,
|
|
2084
|
+
compassDegrees,
|
|
1208
2085
|
decodeGridJson,
|
|
1209
2086
|
decodeWeatherMessage,
|
|
1210
2087
|
directionFromUV,
|
|
1211
2088
|
emptyConditions,
|
|
1212
2089
|
extractReports,
|
|
2090
|
+
findAltitude,
|
|
2091
|
+
findMovement,
|
|
1213
2092
|
forecastAt,
|
|
1214
2093
|
interpolateScalar,
|
|
2094
|
+
isCompass,
|
|
1215
2095
|
latticePoints,
|
|
1216
2096
|
mergeConditions,
|
|
2097
|
+
nudgeScalar,
|
|
2098
|
+
nudgeWind,
|
|
1217
2099
|
parseConditionTokens,
|
|
1218
2100
|
parseGrib2Json,
|
|
1219
2101
|
parseGridJson,
|
|
1220
2102
|
parseMetar,
|
|
2103
|
+
parseNil,
|
|
2104
|
+
parsePosition,
|
|
2105
|
+
parsePositions,
|
|
2106
|
+
parseSigmet,
|
|
2107
|
+
parseSnowtam,
|
|
1221
2108
|
parseTaf,
|
|
2109
|
+
parseVaCloud,
|
|
2110
|
+
parseVaa,
|
|
1222
2111
|
parseWeatherGridSeries,
|
|
1223
2112
|
pointOf,
|
|
1224
2113
|
resolveDayTime,
|