@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/dist/index.js CHANGED
@@ -1,24 +1,47 @@
1
1
  // src/aftn.ts
2
- var KEYWORD = /\b(METAR|SPECI|TAF)\b/;
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 match = KEYWORD.exec(flat);
9
- if (!match) continue;
10
- const body = flat.slice(match.index);
11
- reports.push({ kind: match[1], text: body });
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 CLOUD = /^(FEW|SCT|BKN|OVC)(\d{3}|\/\/\/)(CB|TCU|\/\/\/)?$/;
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 (const token of tokens) {
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
- type: cloud[3] === "CB" || cloud[3] === "TCU" ? cloud[3] : void 0
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) continue;
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
- current.tokens.push(token);
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: _rest, ...conditions } = parseConditionTokens(group.tokens);
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 { kind: "TAF", ...base, cancelled: false, periods };
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 used = [];
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
- used.push(station);
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
- if (used.length === 0) return null;
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
- const report = raw.kind === "TAF" ? parseTaf(raw.text, reference) : parseMetar(raw.text, reference);
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,