@aeroswarm/weather 0.1.5 → 0.3.0

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