@atmosx/event-product-parser 2.0.1

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.
@@ -0,0 +1,46 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+ import * as types from '../types';
16
+
17
+ export class HVtecParser {
18
+
19
+ /**
20
+ * @function HVtecExtractor
21
+ * @description
22
+ * Extracts VTEC entries from a raw NWWS message string and returns
23
+ * structured objects containing type, tracking, event, status,
24
+ * WMO identifiers, and expiry date.
25
+ *
26
+ * @static
27
+ * @param {string} message
28
+ * @returns {Promise<types.HtecEntry[] | null>}
29
+ */
30
+ public static async HVtecExtractor(message: string): Promise<types.HVtecEntry[] | null> {
31
+ const matches = message.match(loader.definitions.regular_expressions.hvtec);
32
+ if (!matches || matches.length !== 1) return null;
33
+ const hvtec = matches[0];
34
+ const parts = hvtec.split('.');
35
+ if (parts.length < 7) return null;
36
+ const hvtecs: types.HVtecEntry[] = [{
37
+ severity: loader.definitions.severity[parts[1]],
38
+ cause: loader.definitions.causes[parts[2]],
39
+ record: loader.definitions.records[parts[6]],
40
+ raw: hvtec,
41
+ }];
42
+ return hvtecs;
43
+ }
44
+ }
45
+
46
+ export default HVtecParser;
@@ -0,0 +1,72 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+ import * as types from '../types';
16
+
17
+ export class PVtecParser {
18
+
19
+ /**
20
+ * @function pVtecExtractor
21
+ * @description
22
+ * Extracts VTEC entries from a raw NWWS message string and returns
23
+ * structured objects containing type, tracking, event, status,
24
+ * WMO identifiers, and expiry date.
25
+ *
26
+ * @static
27
+ * @param {string} message
28
+ * @returns {Promise<types.VtecEntry[] | null>}
29
+ */
30
+ public static async pVtecExtractor(message: string): Promise<types.PVtecEntry[] | null> {
31
+ const matches = message.match(loader.definitions.regular_expressions.pvtec) ?? [];
32
+ const pVtecs: types.PVtecEntry[] = [];
33
+ for (const pvtec of matches) {
34
+ const parts = pvtec.split('.');
35
+ if (parts.length < 7) continue;
36
+ const dates = parts[6].split('-');
37
+ pVtecs.push({
38
+ raw: pvtec,
39
+ type: loader.definitions.productTypes[parts[0]],
40
+ tracking: `${parts[2]}-${parts[3]}-${parts[4]}-${parts[5]}`,
41
+ event: `${loader.definitions.events[parts[3]]} ${loader.definitions.actions[parts[4]]}`,
42
+ status: loader.definitions.status[parts[1]],
43
+ wmo: message.match(loader.definitions.regular_expressions.wmo)?.[0] || null,
44
+ expires: this.parseExpiryDate(dates),
45
+ isKWNS: (parts[4] == `A` || parts[4] == `Y`) && (parts[3] == `TO` || parts[3] == `SV`) ? true : false,
46
+ });
47
+ }
48
+ return pVtecs.length > 0 ? pVtecs : null;
49
+ }
50
+
51
+ /**
52
+ * @function parseExpiryDate
53
+ * @description
54
+ * Converts a NWWS VTEC/expiry timestamp string into a formatted local ISO date string
55
+ * with an Eastern Time offset (-04:00). Returns `Invalid Date Format` if the input
56
+ * is `000000T0000Z`.
57
+ *
58
+ * @private
59
+ * @static
60
+ * @param {string[]} args
61
+ * @returns {string}
62
+ */
63
+ private static parseExpiryDate(args: String[]): string {
64
+ if (args[1] == `000000T0000Z`) return `Invalid Date Format`;
65
+ const expires = `${new Date().getFullYear().toString().substring(0, 2)}${args[1].substring(0, 2)}-${args[1].substring(2, 4)}-${args[1].substring(4, 6)}T${args[1].substring(7, 9)}:${args[1].substring(9, 11)}:00`;
66
+ const local = new Date(new Date(expires).getTime() - 4 * 60 * 60000);
67
+ const pad = (n: number) => n.toString().padStart(2, '0');
68
+ return `${local.getFullYear()}-${pad(local.getMonth() + 1)}-${pad(local.getDate())}T${pad(local.getHours())}:${pad(local.getMinutes())}:00.000-04:00`;
69
+ }
70
+ }
71
+
72
+ export default PVtecParser;
@@ -0,0 +1,97 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+ import * as types from '../types';
16
+
17
+ export class StanzaParser {
18
+
19
+ /**
20
+ * @function validate
21
+ * @description
22
+ * Validates and parses a stanza message, extracting its attributes and metadata.
23
+ * Handles both raw message strings (for debug/testing) and actual stanza objects.
24
+ * Determines whether the message is a CAP alert, contains VTEC codes, or contains UGCs,
25
+ * and identifies the AWIPS product type and prefix.
26
+ *
27
+ * @static
28
+ * @param {any} stanza
29
+ * @param {boolean | types.StanzaAttributes} [isDebug=false]
30
+ * @returns {{
31
+ * message: string;
32
+ * attributes: types.StanzaAttributes;
33
+ * isCap: boolean,
34
+ * isPVtec: boolean;
35
+ * isCapDescription: boolean;
36
+ * awipsType: Record<string, string>;
37
+ * isApi: boolean;
38
+ * ignore: boolean;
39
+ * isUGC?: boolean;
40
+ * }}
41
+ */
42
+ public static validate(stanza: any, isDebug: boolean | types.StanzaAttributes = false): { message: string; attributes: types.StanzaAttributes; isCap: any; isPVtec: boolean; isCapDescription: any; awipsType: any; isApi: boolean; ignore: boolean; isUGC?: boolean; } {
43
+ if (isDebug !== false) {
44
+ const vTypes = isDebug as types.StanzaAttributes;
45
+ const message = stanza;
46
+ const attributes = vTypes;
47
+ const isCap = vTypes.isCap ?? message.includes(`<?xml`);
48
+ const isCapDescription = message.includes(`<areaDesc>`);
49
+ const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
50
+ const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
51
+ const awipsType = this.getType(attributes);
52
+ return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
53
+ }
54
+ if (stanza.is(`message`)) {
55
+ let cb = stanza.getChild(`x`)
56
+ if (cb && cb.children) {
57
+ let message = unescape(cb.children[0]);
58
+ let attributes = cb.attrs
59
+ if (attributes.awipsid && attributes.awipsid.length > 1) {
60
+ const isCap = message.includes(`<?xml`);
61
+ const isCapDescription = message.includes(`<areaDesc>`);
62
+ const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
63
+ const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
64
+ const awipsType = this.getType(attributes);
65
+ return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
66
+ }
67
+ }
68
+ }
69
+ return { message: null, attributes: null, isApi: null, isCap: null, isPVtec: null, isUGC: null, isCapDescription: null, awipsType: null, ignore: true };
70
+ }
71
+
72
+ /**
73
+ * @function getType
74
+ * @description
75
+ * Determines the AWIPS product type and prefix from a stanza's attributes.
76
+ * Returns a default type of 'XX' if the attributes are missing or the AWIPS ID
77
+ * does not match any known definitions.
78
+ *
79
+ * @private
80
+ * @static
81
+ * @param {unknown} attributes
82
+ * @returns {Record<string, string>}
83
+ */
84
+ private static getType(attributes: unknown): Record<string, string> {
85
+ const attrs = attributes as types.StanzaAttributesType | undefined;
86
+ if (!attrs?.awipsid) return { type: 'XX', prefix: 'XX' };
87
+ const awipsDefs = loader.definitions.awips as Record<string, string>;
88
+ for (const [prefix, type] of Object.entries(awipsDefs)) {
89
+ if (attrs.awipsid.startsWith(prefix)) {
90
+ return { type, prefix };
91
+ }
92
+ }
93
+ return { type: 'XX', prefix: 'XX' };
94
+ }
95
+ }
96
+
97
+ export default StanzaParser;
@@ -0,0 +1,165 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+
16
+ export class TextParser {
17
+
18
+ /**
19
+ * @function textProductToString
20
+ * @description
21
+ * Searches a text product message for a line containing a specific value,
22
+ * extracts the substring immediately following that value, and optionally
23
+ * removes additional specified strings. Cleans up the extracted string by
24
+ * trimming whitespace and removing any remaining occurrences of the search
25
+ * value or '<' characters.
26
+ *
27
+ * @static
28
+ * @param {string} message
29
+ * @param {string} value
30
+ * @param {string[]} [removal=[]]
31
+ * @returns {string | null}
32
+ */
33
+ public static textProductToString(message: string,value: string,removal: string[] = []): string | null {
34
+ const lines = message.split('\n');
35
+ for (const line of lines) {
36
+ if (line.includes(value)) {
37
+ let result = line.slice(line.indexOf(value) + value.length).trim();
38
+ for (const str of removal) { result = result.split(str).join(''); }
39
+ result = result.replace(value, '').replace('<', '').trim();
40
+ return result || null;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * @function textProductToPolygon
48
+ * @description
49
+ * Parses a text product message to extract polygon coordinates based on
50
+ * LAT...LON data. Coordinates are converted to [latitude, longitude] pairs
51
+ * with longitude negated (assumes Western Hemisphere). If the polygon has
52
+ * more than two points, the first point is repeated at the end to close it.
53
+ *
54
+ * @static
55
+ * @param {string} message
56
+ * @returns {[number, number][]}
57
+ */
58
+ public static textProductToPolygon(message: string): [number, number][] {
59
+ const coordinates: [number, number][] = [];
60
+ const latLonMatch = message.match(/LAT\.{3}LON\s+([\d\s]+)/i);
61
+ if (!latLonMatch || !latLonMatch[1]) return coordinates;
62
+ const coordStrings = latLonMatch[1].replace(/\n/g, ' ').trim().split(/\s+/);
63
+ for (let i = 0; i < coordStrings.length - 1; i += 2) {
64
+ const lat = parseFloat(coordStrings[i]) / 100;
65
+ const lon = -parseFloat(coordStrings[i + 1]) / 100;
66
+ if (!isNaN(lat) && !isNaN(lon)) { coordinates.push([lon, lat]); }
67
+ }
68
+ if (coordinates.length > 2) { coordinates.push(coordinates[0]); }
69
+ return coordinates;
70
+ }
71
+
72
+ /**
73
+ * @function textProductToDescription
74
+ * @description
75
+ * Extracts a clean description portion from a text product message, optionally
76
+ * removing a handle and any extra metadata such as "STANZA ATTRIBUTES...".
77
+ * Also trims and normalizes whitespace.
78
+ *
79
+ * @static
80
+ * @param {string} message
81
+ * @param {string | null} [handle=null]
82
+ * @returns {string}
83
+ */
84
+ public static textProductToDescription(message: string, handle: string = null): string {
85
+ const original = message;
86
+ const discoveredDates = Array.from(message.matchAll(loader.definitions.regular_expressions.dateline));
87
+ if (discoveredDates.length) {
88
+ const lastMatch = discoveredDates[discoveredDates.length - 1][0];
89
+ const startIdx = message.lastIndexOf(lastMatch);
90
+ if (startIdx !== -1) {
91
+ const endIdx = message.indexOf('&&', startIdx);
92
+ message = message.substring(startIdx + lastMatch.length, endIdx !== -1 ? endIdx : undefined).trimStart();
93
+ if (message.startsWith('/')) message = message.slice(1).trimStart();
94
+ if (handle && message.includes(handle)) {
95
+ const handleIdx = message.indexOf(handle);
96
+ message = message.substring(handleIdx + handle.length).trimStart();
97
+ if (message.startsWith('/')) message = message.slice(1).trimStart();
98
+ }
99
+ }
100
+ } else if (handle) {
101
+ const handleIdx = message.indexOf(handle);
102
+ if (handleIdx !== -1) {
103
+ let afterHandle = message.substring(handleIdx + handle.length).trimStart();
104
+ if (afterHandle.startsWith('/')) afterHandle = afterHandle.slice(1).trimStart();
105
+ const latEnd = afterHandle.indexOf('&&')
106
+ message = latEnd !== -1 ? afterHandle.substring(0, latEnd).trim() : afterHandle.trim();
107
+ }
108
+ }
109
+ return message.replace(/\s+/g, ' ').trim().startsWith('STANZA ATTRIBUTES...') ? original : message.split('STANZA ATTRIBUTES...')[0].trim();
110
+ }
111
+
112
+ /**
113
+ * @function getXmlValues
114
+ * @description
115
+ * Recursively extracts specified values from a parsed XML-like object.
116
+ * Searches both object keys and array items for matching keys (case-insensitive)
117
+ * and returns the corresponding values. If multiple unique values are found for
118
+ * a key, an array is returned; if one value is found, it returns that value;
119
+ * if none are found, returns `null`.
120
+ *
121
+ * @static
122
+ * @param {any} parsed
123
+ * @param {string[]} valuesToExtract
124
+ * @returns {Record<string, string | string[] | null>}
125
+ */
126
+ public static getXmlValues(parsed: any, valuesToExtract: string[]): Record<string, string> {
127
+ const extracted: Record<string, any> = {};
128
+ const findValueByKey = (obj: any, searchKey: string) => {
129
+ const results = [];
130
+ if (obj === null || typeof obj !== 'object') {
131
+ return results;
132
+ }
133
+ const searchKeyLower = searchKey.toLowerCase();
134
+ for (const key in obj) {
135
+ if (obj.hasOwnProperty(key) && key.toLowerCase() === searchKeyLower) {
136
+ results.push(obj[key]);
137
+ }
138
+ }
139
+ if (Array.isArray(obj)) {
140
+ for (const item of obj) {
141
+ if (item.valueName && item.valueName.toLowerCase() === searchKeyLower && item.value !== undefined) {
142
+ results.push(item.value);
143
+ }
144
+ const nestedResults = findValueByKey(item, searchKey);
145
+ results.push(...nestedResults);
146
+ }
147
+ }
148
+ for (const key in obj) {
149
+ if (obj.hasOwnProperty(key)) {
150
+ const nestedResults = findValueByKey(obj[key], searchKey);
151
+ results.push(...nestedResults);
152
+ }
153
+ }
154
+ return results;
155
+ };
156
+ for (const key of valuesToExtract) {
157
+ const values = findValueByKey(parsed.alert, key);
158
+ const uniqueValues = [...new Set(values)];
159
+ extracted[key] = uniqueValues.length === 0 ? null : (uniqueValues.length === 1 ? uniqueValues[0] : uniqueValues);
160
+ }
161
+ return extracted;
162
+ }
163
+ }
164
+
165
+ export default TextParser;
@@ -0,0 +1,247 @@
1
+ /*
2
+ _ _ __ __
3
+ /\ | | | | (_) \ \ / /
4
+ / \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
5
+ / /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
6
+ / ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
7
+ /_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
8
+ | |
9
+ |_|
10
+
11
+ Written by: KiyoWx (k3yomi)
12
+ */
13
+
14
+ import * as loader from '../bootstrap';
15
+ import * as types from '../types';
16
+
17
+ export class UGCParser {
18
+
19
+ /**
20
+ * @function ugcExtractor
21
+ * @description
22
+ * Extracts UGC (Universal Geographic Code) information from a message.
23
+ * This includes parsing the header, resolving zones, calculating the expiry
24
+ * date, and retrieving associated location names from the database.
25
+ *
26
+ * @static
27
+ * @async
28
+ * @param {string} message
29
+ * @returns {Promise<types.UGCEntry | null>}
30
+ */
31
+ public static async ugcExtractor(message: string): Promise<types.UGCEntry | null> {
32
+ const header = this.getHeader(message);
33
+ if (!header) return null;
34
+ const zones = this.getZones(header);
35
+ if (zones.length === 0) return null;
36
+ const expiry = this.getExpiry(message);
37
+ const locations = await this.getLocations(zones);
38
+ return {
39
+ zones: zones,
40
+ locations: locations,
41
+ expiry: expiry
42
+ };
43
+ }
44
+
45
+ /**
46
+ * @function getHeader
47
+ * @description
48
+ * Extracts the UGC header from a message by locating patterns defined in
49
+ * `ugc1` and `ugc2` regular expressions. Removes all whitespace and the
50
+ * trailing character from the matched header.
51
+ *
52
+ * @static
53
+ * @param {string} message
54
+ * @returns {string | null}
55
+ */
56
+ public static getHeader(message: string): string | null {
57
+ const start = message.search(loader.definitions.regular_expressions.ugc1);
58
+ const subMessage = message.substring(start);
59
+ const end = subMessage.search(loader.definitions.regular_expressions.ugc2);
60
+ const full = subMessage.substring(0, end).replace(/\s+/g, '').slice(0, -1);
61
+ return full || null;
62
+ }
63
+
64
+ /**
65
+ * @function getExpiry
66
+ * @description
67
+ * Extracts an expiration date from a message using the UGC3 format.
68
+ * The function parses day, hour, and minute from the message and constructs
69
+ * a Date object in the current month and year. Returns `null` if no valid
70
+ * expiration is found.
71
+ *
72
+ * @static
73
+ * @param {string} message
74
+ * @returns {Date | null}
75
+ */
76
+ public static getExpiry(message: string): Date | null {
77
+ const match = message.match(/\b(\d{6})-/);
78
+ if (!match) return null;
79
+ const token = match[1];
80
+ const day = parseInt(token.slice(0, 2), 10);
81
+ const hour = parseInt(token.slice(2, 4), 10);
82
+ const minute = parseInt(token.slice(4, 6), 10);
83
+ const now = new Date();
84
+ const expires = new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),day,hour,minute));
85
+ return expires;
86
+ }
87
+
88
+ /**
89
+ * @function getLocations
90
+ * @description
91
+ * Retrieves human-readable location names for an array of zone identifiers
92
+ * from the shapefiles database. If a zone is not found, the zone ID itself
93
+ * is returned. Duplicate locations are removed and the result is sorted.
94
+ *
95
+ * @static
96
+ * @async
97
+ * @param {string[]} zones
98
+ * @returns {Promise<string[]>}
99
+ */
100
+ public static async getLocations(zones: string[]): Promise<string[]> {
101
+ const uniqueZones = Array.from(new Set(zones.map(z => z.trim())));
102
+ const placeholders = uniqueZones.map(() => '?').join(',');
103
+ const rows = await loader.cache.db.prepare(
104
+ `SELECT id, location FROM shapefiles WHERE id IN (${placeholders})`
105
+ ).all(...uniqueZones);
106
+ const locationMap = new Map<string, string>();
107
+ for (const row of rows) { locationMap.set(row.id, row.location) }
108
+ const locations = uniqueZones.map(id => locationMap.get(id) ?? id);
109
+ return locations.sort();
110
+ }
111
+
112
+ /**
113
+ * @function getCoordinates
114
+ * @description
115
+ * Calculates the outer boundary coordinates for a set of UGC zones by
116
+ * querying their geometries from the database, merging them, and extracting
117
+ * the largest outer ring. The coordinates are downsampled based on a skip
118
+ * setting to reduce complexity. Returns `null` if no valid coordinates are found.
119
+ *
120
+ * @static
121
+ * @param {string[]} zones
122
+ * @returns {[number, number][]}
123
+ */
124
+ public static getCoordinates(zones: string[], isUnion=true): any | null {
125
+ const list = [...new Set(zones.map(z => z.trim()))].filter(z => z === 'XX000' ? false : true);
126
+ if (list.length === 0) return null;
127
+ const placeholders = list.map(() => "?").join(",");
128
+ const rows = loader.cache.db
129
+ .prepare(`SELECT geometry FROM shapefiles WHERE id IN (${placeholders})`)
130
+ .all(...list);
131
+ const polygons: any[] = [];
132
+ for (const row of rows) {
133
+ if (!row?.geometry) continue;
134
+ const geom = JSON.parse(row.geometry);
135
+ if (geom?.type === "Polygon") {
136
+ polygons.push(geom.coordinates);
137
+ }
138
+ }
139
+ if (polygons.length === 0) return null;
140
+ if (isUnion) {
141
+ const unionFn = loader.packages.polygonClipping.union as (...polys: any[]) => any;
142
+ const mergedCoords = unionFn(...polygons);
143
+ if (!mergedCoords || mergedCoords.length === 0) return null;
144
+ let maxArea = -1;
145
+ let bestPoly: any[] = [];
146
+ for (const poly of mergedCoords) {
147
+ const outerRing = poly[0];
148
+ let area = 0;
149
+ for (let i = 0; i < outerRing.length - 1; i++) {
150
+ const [x1, y1] = outerRing[i];
151
+ const [x2, y2] = outerRing[i + 1];
152
+ area += x1 * y2 - x2 * y1;
153
+ }
154
+ area = Math.abs(area / 2);
155
+ if (area > maxArea) {
156
+ maxArea = area;
157
+ bestPoly = poly;
158
+ }
159
+ }
160
+ if (!bestPoly || bestPoly.length === 0) return null;
161
+ const outerRing = bestPoly[0];
162
+ const skip = Math.max(1, parseInt(String(loader.settings.global_settings.shapefile_skip), 10) || 1);
163
+ let skipped = outerRing.filter((_: any, idx: number) => idx % skip === 0);
164
+ if (skipped.length < 4) {
165
+ skipped = outerRing.slice();
166
+ }
167
+ const first = skipped[0];
168
+ const last = skipped[skipped.length - 1];
169
+ if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
170
+ skipped.push([first[0], first[1]]);
171
+ }
172
+ return {type: "Polygon", coordinates: [skipped]};
173
+ } else {
174
+ const multi: any[] = [];
175
+ for (const polyCoords of polygons) {
176
+ if (Array.isArray(polyCoords) && Array.isArray(polyCoords[0])) {
177
+ multi.push(polyCoords);
178
+ }
179
+ }
180
+ if (multi.length === 0) return null;
181
+ const skip = Math.max(1, parseInt(String(loader.settings.global_settings.shapefile_skip), 10) || 1);
182
+ if (skip > 1) {
183
+ for (let p = 0; p < multi.length; p++) {
184
+ for (let r = 0; r < multi[p].length; r++) {
185
+ const ring = multi[p][r];
186
+ let reduced = ring.filter((_: any, i: number) => i % skip === 0);
187
+ if (reduced.length < 4) reduced = ring.slice();
188
+ const first = reduced[0];
189
+ const last = reduced[reduced.length - 1];
190
+ if ( first && last && (first[0] !== last[0] || first[1] !== last[1])) {
191
+ reduced.push([first[0], first[1]]);
192
+ }
193
+ multi[p][r] = reduced;
194
+ }
195
+ }
196
+ }
197
+ return {type: "MultiPolygon", coordinates: multi};
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @function getZones
203
+ * @description
204
+ * Parses a UGC header string and returns an array of individual zone
205
+ * identifiers. Handles ranges indicated with `>` and preserves the
206
+ * state and format prefixes.
207
+ *
208
+ * @static
209
+ * @param {string} header
210
+ * @returns {string[]}
211
+ */
212
+ public static getZones(header: string): string[] {
213
+ const ugcSplit = header.split('-');
214
+ const zones: string[] = [];
215
+ let state = ugcSplit[0].substring(0, 2);
216
+ const format = ugcSplit[0].substring(2, 3);
217
+ for (const part of ugcSplit) {
218
+ if (/^[A-Z]/.test(part)) {
219
+ state = part.substring(0, 2);
220
+ if (part.includes('>')) {
221
+ const [start, end] = part.split('>');
222
+ const startNum = parseInt(start.substring(3), 10);
223
+ const endNum = parseInt(end, 10);
224
+ for (let j = startNum; j <= endNum; j++) {
225
+ zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
226
+ }
227
+ } else {
228
+ zones.push(part);
229
+ }
230
+ continue;
231
+ }
232
+ if (part.includes('>')) {
233
+ const [start, end] = part.split('>');
234
+ const startNum = parseInt(start, 10);
235
+ const endNum = parseInt(end, 10);
236
+ for (let j = startNum; j <= endNum; j++) {
237
+ zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
238
+ }
239
+ } else {
240
+ zones.push(`${state}${format}${part}`);
241
+ }
242
+ }
243
+ return zones.filter(item => item !== '');
244
+ }
245
+ }
246
+
247
+ export default UGCParser;