diff --git a/.changeset/blue-teachers-share.md b/.changeset/blue-teachers-share.md new file mode 100644 index 00000000..db76c157 --- /dev/null +++ b/.changeset/blue-teachers-share.md @@ -0,0 +1,7 @@ +--- +"@accelint/geo": minor +--- + +Add coordinate parsing capability; parse string into object with conversion +options: MGRS, UTM, and lat/lon DD, DDM, DMS. Some error messaging is included +to be helpful for users and debuggers. diff --git a/packages/geo/package.json b/packages/geo/package.json index 8b30284b..fabbc33b 100644 --- a/packages/geo/package.json +++ b/packages/geo/package.json @@ -40,6 +40,8 @@ "dependencies": { "@accelint/math": "workspace:0.1.3", "@accelint/predicates": "workspace:0.1.3", + "@ngageoint/mgrs-js": "^1.0.0", + "@ngageoint/grid-js": "^2.1.0", "typescript": "^5.6.3" }, "$schema": "https://json.schemastore.org/package", diff --git a/packages/geo/src/cartesian.ts b/packages/geo/src/cartesian.ts new file mode 100644 index 00000000..59236036 --- /dev/null +++ b/packages/geo/src/cartesian.ts @@ -0,0 +1,39 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Computes the Cartesian product of multiple arrays. + * + * @template T + * the type of elements in the input arrays. + * @param {...T[][]} all + * a variadic number of arrays to compute the Cartesian product of. + * @returns {T[][]} + * An array containing all possible combinations of elements from the input + * arrays, where each combination is represented as an array. + * + * @pure + * + * @example + * const result = cartesian([1, 2], ['a', 'b']); + * // result: [[1, 'a'], [1, 'b'], [2, 'a'], [2, 'b']] + */ +export function cartesian(...all: T[][]): T[][] { + return all.reduce( + (results, entries) => + results + .map((result) => entries.map((entry) => result.concat([entry]))) + .reduce((sub, res) => sub.concat(res), []), + [[]], + ); +} diff --git a/packages/geo/src/coordinates/README.md b/packages/geo/src/coordinates/README.md deleted file mode 100644 index 40294caf..00000000 --- a/packages/geo/src/coordinates/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Ethos - -1. parse a coordinate into deterministic capture groups -2. take deterministic capture group values and normalize to decimal values -3. format decimal values to user defined reading/writing output -4. use capture groups from 1. to validate editing 3. diff --git a/packages/geo/src/coordinates/__fixtures__/index.ts b/packages/geo/src/coordinates/__fixtures__/index.ts deleted file mode 100644 index b53a8db8..00000000 --- a/packages/geo/src/coordinates/__fixtures__/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -// const LAT_DD = 40.689247; -// const LON_DD = -74.044502; - -export const ddPairs = [ - ['partial', '40, -74'], - ['partial directions prefix', 'N 40, W 74'], - ['partial directions prefix no space', 'N40, W74'], - ['partial directions suffix', '40 N, 74 W'], - ['partial directions suffix no space', '40N, 74W'], - ['plus minus', '+40.689247, -74.044502'], - ['inferred plus', '40.689247, -74.044502'], - ['inferred positive direction', '40.689247, 74.044502 W'], - ['degree delimeters', '40.689247° N, 74.044502° W'], - ['separator ,', '40.689247,-74.044502'], - ['separator /', '40.689247/-74.044502'], - ['separator space', '40.689247 -74.044502'], - ['NSEW directions prefix', 'N 40.689247, W 74.044502'], - ['NSEW directions prefix no space', 'N40.689247, W74.044502'], - ['NSEW directions suffix', '40.689247 N, 74.044502 W'], - ['NSEW directions suffix no space', '40.689247N, 74.044502W'], - ['mixed plus minus and directions', '+40.689247, W74.044502'], - ['mixed full and partial', '40.689247, W74'], - ['odd whitespace', ' + 40.689247 , - 74.044502 '], -]; - -// const LAT_DMS = `40° 41' 21.2892"`; -// const LON_DMS = `-74° 02' 40.2066"`; - -export const dmsPairs = [ - ['partial no delimeters', '40 41 -74 02'], - ['partial delimeters', `40° 41' -74 02'`], - ['plus minus no delimeters', '+40 41 21.2892 -74 02 40.2066'], - ['plus minus delimeters', `+40° 41' 21.2892" -74° 02' 40.2066"`], - ['directions no delimeters', '40 41 21.2892 N 74 02 40.2066 W'], - ['directions delimeters', `40° 41' 21.2892" N 74° 02' 40.2066" W`], - ['separator ,', `40° 41' 21.2892",-74° 02' 40.2066"`], - ['separator /', `40° 41' 21.2892"/-74° 02' 40.2066"`], - ['separator space', `40° 41' 21.2892" -74° 02' 40.2066"`], -]; - -export const ddmPairs = []; - -export const mixedPairs = []; diff --git a/packages/geo/src/coordinates/__snapshots__/match.test.ts.snap b/packages/geo/src/coordinates/__snapshots__/match.test.ts.snap deleted file mode 100644 index dd7fe2b9..00000000 --- a/packages/geo/src/coordinates/__snapshots__/match.test.ts.snap +++ /dev/null @@ -1,457 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`coordinates > degrees minutes seconds > directions delimeters: 40° 41' 21.2892" N 74° 02' 40.2066" W 1`] = ` -[ - "", - "", - "40", - "41", - "21", - ".2892", - "N", - "", - "", - "74", - "02", - "40", - ".2066", - "W", -] -`; - -exports[`coordinates > degrees minutes seconds > directions no delimeters: 40 41 21.2892 N 74 02 40.2066 W 1`] = ` -[ - "", - "", - "40", - "41", - "21", - ".2892", - "N", - "", - "", - "74", - "02", - "40", - ".2066", - "W", -] -`; - -exports[`coordinates > degrees minutes seconds > partial delimeters: 40° 41' -74 02' 1`] = ` -[ - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > partial no delimeters: 40 41 -74 02 1`] = ` -[ - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > plus minus delimeters: +40° 41' 21.2892" -74° 02' 40.2066" 1`] = ` -[ - "", - "+", - "40", - "41", - "21", - ".2892", - "", - "", - "-", - "74", - "02", - "40", - ".2066", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > plus minus no delimeters: +40 41 21.2892 -74 02 40.2066 1`] = ` -[ - "", - "+", - "40", - "41", - "21", - ".2892", - "", - "", - "-", - "74", - "02", - "40", - ".2066", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > separator ,: 40° 41' 21.2892",-74° 02' 40.2066" 1`] = ` -[ - "", - "", - "40", - "41", - "21", - ".2892", - "", - "", - "-", - "74", - "02", - "40", - ".2066", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > separator /: 40° 41' 21.2892"/-74° 02' 40.2066" 1`] = ` -[ - "", - "", - "40", - "41", - "21", - ".2892", - "", - "", - "-", - "74", - "02", - "40", - ".2066", - "", -] -`; - -exports[`coordinates > degrees minutes seconds > separator space: 40° 41' 21.2892" -74° 02' 40.2066" 1`] = ` -[ - "", - "", - "40", - "41", - "21", - ".2892", - "", - "", - "-", - "74", - "02", - "40", - ".2066", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > NSEW directions prefix no space: N40.689247, W74.044502 1`] = ` -[ - "N", - "", - "40", - ".689247", - "", - "W", - "", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > NSEW directions prefix: N 40.689247, W 74.044502 1`] = ` -[ - "N", - "", - "40", - ".689247", - "", - "W", - "", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > NSEW directions suffix no space: 40.689247N, 74.044502W 1`] = ` -[ - "", - "", - "40", - ".689247", - "N", - "", - "", - "74", - ".044502", - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > NSEW directions suffix: 40.689247 N, 74.044502 W 1`] = ` -[ - "", - "", - "40", - ".689247", - "N", - "", - "", - "74", - ".044502", - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > degree delimeters: 40.689247° N, 74.044502° W 1`] = ` -[ - "", - "", - "40", - ".689247", - "N", - "", - "", - "74", - ".044502", - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > inferred plus: 40.689247, -74.044502 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > inferred positive direction: 40.689247, 74.044502 W 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "", - "", - "74", - ".044502", - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > mixed full and partial: 40.689247, W74 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "W", - "", - "74", - undefined, - "", -] -`; - -exports[`coordinates > matching > decimal degrees > mixed plus minus and directions: +40.689247, W74.044502 1`] = ` -[ - "", - "+", - "40", - ".689247", - "", - "W", - "", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > odd whitespace: + 40.689247 , - 74.044502 1`] = ` -[ - "", - "+", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > partial directions prefix no space: N40, W74 1`] = ` -[ - "N", - "", - "40", - undefined, - "", - "W", - "", - "74", - undefined, - "", -] -`; - -exports[`coordinates > matching > decimal degrees > partial directions prefix: N 40, W 74 1`] = ` -[ - "N", - "", - "40", - undefined, - "", - "W", - "", - "74", - undefined, - "", -] -`; - -exports[`coordinates > matching > decimal degrees > partial directions suffix no space: 40N, 74W 1`] = ` -[ - "", - "", - "40", - undefined, - "N", - "", - "", - "74", - undefined, - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > partial directions suffix: 40 N, 74 W 1`] = ` -[ - "", - "", - "40", - undefined, - "N", - "", - "", - "74", - undefined, - "W", -] -`; - -exports[`coordinates > matching > decimal degrees > partial: 40, -74 1`] = ` -[ - "", - "", - "40", - undefined, - "", - "", - "-", - "74", - undefined, - "", -] -`; - -exports[`coordinates > matching > decimal degrees > plus minus: +40.689247, -74.044502 1`] = ` -[ - "", - "+", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > separator ,: 40.689247,-74.044502 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > separator /: 40.689247/-74.044502 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; - -exports[`coordinates > matching > decimal degrees > separator space: 40.689247 -74.044502 1`] = ` -[ - "", - "", - "40", - ".689247", - "", - "", - "-", - "74", - ".044502", - "", -] -`; diff --git a/packages/geo/src/coordinates/configurations.ts b/packages/geo/src/coordinates/configurations.ts deleted file mode 100644 index 27231830..00000000 --- a/packages/geo/src/coordinates/configurations.ts +++ /dev/null @@ -1,18 +0,0 @@ -// __private-exports - -import { DD_LAT, DD_LON, SEPARATORS, DMS_LAT, DMS_LON } from './regex'; - -/** - * NOTE: these pairs are setup to support different lon/lat ordering - * 0 = latitude first, 1 = longitude first - */ - -export const dd = [ - new RegExp(DD_LAT + SEPARATORS + DD_LON, 'i'), - new RegExp(DD_LON + SEPARATORS + DD_LAT, 'i'), -] as const; - -export const dms = [ - new RegExp(DMS_LAT + SEPARATORS + DMS_LON, 'i'), - new RegExp(DMS_LON + SEPARATORS + DMS_LAT, 'i'), -] as const; diff --git a/packages/geo/src/coordinates/coordinate.ts b/packages/geo/src/coordinates/coordinate.ts new file mode 100644 index 00000000..2e604063 --- /dev/null +++ b/packages/geo/src/coordinates/coordinate.ts @@ -0,0 +1,212 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { systemDecimalDegrees } from './latlon/decimal-degrees/system'; +import { systemDegreesDecimalMinutes } from './latlon/degrees-decimal-minutes/system'; +import { systemDegreesMinutesSeconds } from './latlon/degrees-minutes-seconds/system'; +import { + type Axes, + type Errors, + type Format, + FORMATS_DEFAULT, + SYMBOLS, +} from './latlon/internal'; +import type { CoordinateSystem } from './latlon/internal/coordinate-sytem'; +import { + createCache, + type CoordinateCache, +} from './latlon/internal/create-cache'; +import type { Tokens } from './latlon/internal/lexer'; +import { systemMGRS } from './mgrs/system'; +import { systemUTM } from './utm/system'; + +// biome-ignore lint/suspicious/noExplicitAny: +type MinLengthArray = [any, any]; + +type AnySystem = CoordinateSystem; + +/** + * Output a string value of a coordinate using an available system. The + * original value is preserved without conversion to an internal + * representation - Decimal Degrees - to prevent the possibility of + * rounding errors. All alternative values are computed from a common + * internal value to reduce complexity. + * + * @pure + * + * @example + * const create = createCoordinate(coordinateSystems.dd, 'LATLON') + * const coord = create('89.765432109 / 123.456789012') + * + * // honors the instantiation format 'LATLON' + * coord.dd() === '89.765432109 N / 123.456789012 E' + * coord.ddm() === '89 45.92592654 N / 123 27.40734072 E' + * coord.dms() === '89 45 55.5555924 N / 123 27 24.4404432 E' + * + * // change format to 'LONLAT' + * coord.dms('LONLAT') === '123 27 24.4404432 E / 89 45 55.5555924 N' + */ +type Formatter = (f?: Format) => string; + +type Coordinate = { + /** @see {@link Formatter} */ + dd: Formatter; + /** @see {@link Formatter} */ + ddm: Formatter; + /** @see {@link Formatter} */ + dms: Formatter; + /** @see {@link Formatter} */ + mgrs: Formatter; + /** @see {@link Formatter} */ + utm: Formatter; + errors: string[]; + raw: CoordinateInternalValue; + valid: boolean; +}; + +// biome-ignore lint/style/useNamingConvention: consistency +type CoordinateInternalValue = { LAT: number; LON: number }; + +type OutputCache = Record; + +export const coordinateSystems = Object.freeze({ + dd: systemDecimalDegrees, + ddm: systemDegreesDecimalMinutes, + dms: systemDegreesMinutesSeconds, + mgrs: systemMGRS, + utm: systemUTM, +} as const); + +const DEFAULT_SYSTEM = coordinateSystems.dd; + +const freezeCoordinate = ( + errors: Coordinate['errors'], + to: (s?: CoordinateSystem, f?: Format) => string, + raw: CoordinateInternalValue, + valid: Coordinate['valid'], +) => + Object.freeze({ + dd: (format?: Format) => to(systemDecimalDegrees, format), + ddm: (format?: Format) => to(systemDegreesDecimalMinutes, format), + dms: (format?: Format) => to(systemDegreesMinutesSeconds, format), + mgrs: (format?: Format) => to(systemMGRS, format), + errors, + raw, + valid, + } as Coordinate); + +/** + * Create a coordinate object enabling: lexing, parsing, validation, and + * formatting in alternative systems and formats. The system and format will be + * used for validation and eventually for output as defaults if no alternatives + * are provided. + * + * @param initSystem dd, ddm, or dms + * + * @pure + * + * @example + * const create = createCoordinate(coordinateSystems.dd, 'LATLON') + * const create = createCoordinate(coordinateSystems.ddm, 'LONLAT') + */ +export function createCoordinate( + initSystem: AnySystem = DEFAULT_SYSTEM, + initFormat: Format = FORMATS_DEFAULT, +) { + return (input: string) => { + let tokens: Tokens; + let errors: Errors; + + try { + [tokens, errors] = initSystem.parse(initFormat, input); + + if (errors.length) { + throw errors; + } + } catch (errors) { + return freezeCoordinate( + errors as Coordinate['errors'], + () => '', + {} as CoordinateInternalValue, + false, + ); + } + + // start with the original value for the original system in the original format + // other values will be computed as needed and cached per request + const cachedValues = { + [initSystem.name]: createCache( + initFormat, + // because mgrs doesn't have two formats: LATLON v LONLAT + initSystem.name === systemMGRS.name ? input : tokens.join(' '), + ), + } as OutputCache; + + const raw = internalRepresentation(initFormat, initSystem, tokens); + + const to = ( + system: AnySystem = initSystem, + format: Format = initFormat, + ) => { + const key = system.name as keyof typeof coordinateSystems; + + if (!cachedValues[key]?.[format]) { + // cache "miss" - fill the missing value in the cache before returning it + + // update the cache to include the newly computed value + cachedValues[key] = { + ...cachedValues[key], + // use the Format to build the object, correctly pairing the halves of + // the coordinate value with their labels + [format]: system.toFormat( + format, + ([format.slice(0, 3), format.slice(3)] as Axes[]).map( + (key) => raw[key], + ) as [number, number], + ), + }; + } + + return cachedValues[key][format]; + }; + + return freezeCoordinate([] as Coordinate['errors'], to, raw, true); + }; +} + +/** + * Create the "internal" representation - Decimal Degrees - for consistency and + * ease of computation; all systems expect to start from a common starting + * point to reduce complexity. + * + * @pure + */ +function internalRepresentation( + initFormat: Format, + { toFloat }: AnySystem, + tokens: Tokens, +) { + return Object.fromEntries([ + [ + initFormat.slice(0, 3), + toFloat( + tokens.slice(0, tokens.indexOf(SYMBOLS.DIVIDER)) as MinLengthArray, + ), + ], + [ + initFormat.slice(3), + toFloat( + tokens.slice(1 + tokens.indexOf(SYMBOLS.DIVIDER)) as MinLengthArray, + ), + ], + ]) as CoordinateInternalValue; +} diff --git a/packages/geo/src/coordinates/latlon/README.md b/packages/geo/src/coordinates/latlon/README.md new file mode 100644 index 00000000..4d80051b --- /dev/null +++ b/packages/geo/src/coordinates/latlon/README.md @@ -0,0 +1,23 @@ +# Latitude and Longitude Coordinate Parsing + +This library is designed to parse strings that resemble latitude and longitude coordinates and convert them into usable values. The goal is to handle a wide variety of formats as intuitively as possible, without over-interpreting input in a way that might lead to incorrect results. + +## Parsing Stages + +The parsing process occurs in three main stages to extract accurate coordinate values: + +1. Lexing +2. Raw Parsing +3. Format/Specific Parsing + +### Lexing + +In the lexing phase, all extraneous characters are removed to facilitate "tokenization." The output of this stage is a refined sequence of character groupings that are meaningful and indicative of the intended coordinate value. + +### Raw Parsing + +During this phase, certain disqualifying errors are detected to halt further processing when it's clearly unnecessary. Additionally, some characters (particularly dividers) may be added where appropriate to ensure consistent interpretation in the next stage. + +### Format/Specific Parsing + +This is the primary interface for library users. Specific parsing targets well-known coordinate formats, delivering results that are useful and relevant to most applications, rather than focusing on lower-level parsing details. diff --git a/packages/geo/src/coordinates/latlon/decimal-degrees/parser.ts b/packages/geo/src/coordinates/latlon/decimal-degrees/parser.ts new file mode 100644 index 00000000..0fa5b052 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/decimal-degrees/parser.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as Patterning from '@/patterning'; + +import { + BEARINGS, + LIMITS, + PARTIAL_PATTERNS, + SYMBOL_PATTERNS, + SYMBOLS, + type Format, +} from '../internal'; +import { createParser } from '../internal/parse-format'; +import type { ParseResults } from '../internal/parse'; + +type DecimalDegrees = { + bear: string; + deg: string; +}; + +const checks = { + deg: (deg: string, limit: number) => { + if (Number.parseFloat(deg) > limit) { + return `Degrees value (${deg}) exceeds max value (${limit}).`; + } + }, + min: (val: string) => { + if (val.includes(SYMBOLS.MINUTES)) { + return `Seconds indicator (${SYMBOLS.MINUTES}) not valid in Decimal Degrees.`; + } + }, + sec: (val: string) => { + if (val.includes(SYMBOLS.SECONDS)) { + return `Seconds indicator (${SYMBOLS.SECONDS}) not valid in Decimal Degrees.`; + } + }, +}; + +const formats = { + LATLON: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLatDec NS ${SYMBOLS.DIVIDER} degLonDec EW`, + ), + LONLAT: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLonDec EW ${SYMBOLS.DIVIDER} degLatDec NS`, + ), +}; + +function identifyErrors(format: Format) { + return (arg: DecimalDegrees | undefined, i: number) => { + if (!arg) { + return [[], ['Invalid coordinate value.']] as ParseResults; + } + + let { bear, deg } = arg; + + deg ??= '0'; + + let isNegative: 0 | 1 = SYMBOL_PATTERNS.NEGATIVE_SIGN.test(deg) ? 1 : 0; + const bearingOptions = BEARINGS[format][i] as [string, string]; + + if (!bear || isNegative) { + bear = bearingOptions[isNegative]; + deg = Math.abs(Number.parseFloat(deg)).toString(); + isNegative = 0; + } + + const errors = [ + ...[checks.deg(deg, LIMITS[format][i] ?? 0)], + ...[checks.min([bear, deg].join(''))], + ...[checks.sec([bear, deg].join(''))], + ].filter(Boolean); + + return (errors.length ? [[], errors] : [[deg, bear], []]) as ParseResults; + }; +} + +function identifyPieces(half: string[]) { + if (half.length > 2 || half.length < 1) { + return; + } + + const places = { bear: '', deg: '' }; + + return half.reduce((acc, token) => { + if (SYMBOL_PATTERNS.NSEW.test(token) && !acc.bear) { + acc.bear ||= token; + } else { + acc.deg ||= token; + } + + return acc; + }, places); +} + +/** Parse a Decimal Degrees coordinate. */ +export const parseDecimalDegrees = createParser({ + formats, + identifyErrors, + identifyPieces, +}); diff --git a/packages/geo/src/coordinates/latlon/decimal-degrees/system.ts b/packages/geo/src/coordinates/latlon/decimal-degrees/system.ts new file mode 100644 index 00000000..175d255d --- /dev/null +++ b/packages/geo/src/coordinates/latlon/decimal-degrees/system.ts @@ -0,0 +1,43 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + BEARINGS, + type Compass, + type Format, + SYMBOL_PATTERNS, + SYMBOLS, +} from '../internal'; +import type { CoordinateSystem } from '../internal/coordinate-sytem'; + +import { parseDecimalDegrees } from './parser'; + +type ToFloat = [string, Compass]; + +export const systemDecimalDegrees: CoordinateSystem = { + name: 'Decimal Degrees', + + parse: parseDecimalDegrees, + + toFloat: ([num, bear]) => + Number.parseFloat(num) * + (SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(bear) ? -1 : 1), + + toFormat: (format: Format, [left, right]: [number, number]) => + [left, right] + .map( + (num, index) => + `${Math.abs(num)} ${BEARINGS[format][index as 0 | 1][+(num < 0)]}`, + ) + .join(` ${SYMBOLS.DIVIDER} `), +}; diff --git a/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/parser.ts b/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/parser.ts new file mode 100644 index 00000000..ea286afc --- /dev/null +++ b/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/parser.ts @@ -0,0 +1,127 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as Patterning from '@/patterning'; + +import { + BEARINGS, + LIMITS, + PARTIAL_PATTERNS, + SYMBOL_PATTERNS, + SYMBOLS, + type Format, +} from '../internal'; +import { inRange } from '../internal/in-range'; +import { createParser } from '../internal/parse-format'; +import type { ParseResults } from '../internal/parse'; + +type DegreesDecimalMinutes = { + bear: string; + deg: string; + min: string; +}; + +const checks = { + deg: (deg: string, limit: number) => { + if (Number.parseFloat(deg) > limit) { + return `Degrees value (${deg}) exceeds max value (${limit}).`; + } + + if (/\./.test(deg)) { + return `Degrees value (${deg}) must not include decimal value.`; + } + }, + min: (min: string) => inRange('Minutes', min, 59.999999999), + sec: (val: string) => { + if (val.includes(SYMBOLS.SECONDS)) { + return `Seconds indicator (${SYMBOLS.SECONDS}) not valid in Degree Decimal Minutes.`; + } + }, +}; + +const formats = { + LATLON: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLat minDec NS ${SYMBOLS.DIVIDER} degLon minDec EW`, + ), + LONLAT: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLon minDec EW ${SYMBOLS.DIVIDER} degLat minDec NS`, + ), +}; + +function identifyErrors(format: Format) { + return (arg: DegreesDecimalMinutes | undefined, i: number) => { + if (!arg) { + return [[], ['Invalid coordinate value.']] as ParseResults; + } + + let { bear, deg, min } = arg; + + deg ??= '0'; + min ??= '0'; + + let isNegative: 0 | 1 = SYMBOL_PATTERNS.NEGATIVE_SIGN.test(deg) ? 1 : 0; + const bearingOptions = BEARINGS[format][i] as [string, string]; + + if (!bear || isNegative) { + bear = bearingOptions[isNegative]; + deg = Math.abs(Number.parseFloat(deg)).toString(); + isNegative = 0; + } + + const errors = [ + ...[checks.deg(deg, LIMITS[format][i] ?? 0)], + ...[checks.min(min)], + ...[checks.sec([bear, deg, min].join(''))], + ].filter(Boolean); + + return ( + errors.length ? [[], errors] : [[deg, min, bear], []] + ) as ParseResults; + }; +} + +function identifyPieces(half: string[]) { + if (half.length < 1 || half.length > 3) { + return; + } + + const asString = half.join(' '); + const places = { bear: '', deg: '', min: '' }; + const keys = ['deg', 'min'] as (keyof typeof places)[]; + const test = (r: RegExp, b: boolean, v: string) => + r.test(v) || (r.test(asString) && b); + + return half.reduce((acc, token, i, { length }) => { + if (test(SYMBOL_PATTERNS.NSEW, i === length - 1, token)) { + acc.bear ||= token; + } else if (test(SYMBOL_PATTERNS.DEGREES, i === 0, token)) { + acc.deg ||= token; + } else if (test(SYMBOL_PATTERNS.MINUTES, i === 1, token)) { + acc.min ||= token; + } else { + const key = keys.find((k) => !acc[k]); + + acc[key as keyof typeof acc] = token; + } + + return acc; + }, places); +} + +/** Parse a Degrees Decimal Minutes coordinate. */ +export const parseDegreesDecimalMinutes = createParser({ + formats, + identifyErrors, + identifyPieces, +}); diff --git a/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/system.ts b/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/system.ts new file mode 100644 index 00000000..15eb3bb0 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/degrees-decimal-minutes/system.ts @@ -0,0 +1,51 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + BEARINGS, + type Compass, + type Format, + SYMBOL_PATTERNS, + SYMBOLS, +} from '../internal'; +import type { CoordinateSystem } from '../internal/coordinate-sytem'; + +import { parseDegreesDecimalMinutes } from './parser'; + +type ToFloat = [string, string, Compass]; + +export const systemDegreesDecimalMinutes: CoordinateSystem = { + name: 'Degrees Decimal Minutes', + + parse: parseDegreesDecimalMinutes, + + toFloat: ([degrees, minutes, bear]) => + Number.parseFloat( + ( + (Number.parseFloat(degrees) + Number.parseFloat(minutes) / 60) * + (SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(bear) ? -1 : 1) + ).toFixed(9), + ), + + toFormat: (format: Format, [left, right]: [number, number]) => { + return [left, right] + .map((num, index) => { + const abs = Math.abs(num); + const deg = Math.floor(abs); + const min = Number.parseFloat(((abs - deg) * 60).toFixed(9)); + + return `${deg} ${min} ${BEARINGS[format][index as 0 | 1][+(num < 0)]}`; + }) + .join(` ${SYMBOLS.DIVIDER} `); + }, +}; diff --git a/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/parser.ts b/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/parser.ts new file mode 100644 index 00000000..97aef08f --- /dev/null +++ b/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/parser.ts @@ -0,0 +1,128 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as Patterning from '@/patterning'; + +import { + BEARINGS, + LIMITS, + PARTIAL_PATTERNS, + SYMBOL_PATTERNS, + SYMBOLS, + type Format, +} from '../internal'; +import { inRange } from '../internal/in-range'; +import { createParser } from '../internal/parse-format'; +import type { ParseResults } from '../internal/parse'; + +type DegreesMinutesSeconds = { + bear: string; + deg: string; + min: string; + sec: string; +}; + +const checks = { + deg: (deg: string, limit: number) => { + if (Number.parseFloat(deg) > limit) { + return `Degrees value (${deg}) exceeds max value (${limit}).`; + } + + if (/\./.test(deg)) { + return `Degrees value (${deg}) must not include decimal value.`; + } + }, + min: (min: string) => inRange('Minutes', min, 59), + sec: (sec: string) => inRange('Seconds', sec, 59.999999999), +}; + +const formats = { + LATLON: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLat min secDec NS ${SYMBOLS.DIVIDER} degLon min secDec EW`, + ), + LONLAT: Patterning.fromTemplate( + PARTIAL_PATTERNS, + `degLon min secDec EW ${SYMBOLS.DIVIDER} degLat min secDec NS`, + ), +}; + +function identifyErrors(format: Format) { + return (arg: DegreesMinutesSeconds | undefined, i: number) => { + if (!arg) { + return [[], ['Invalid coordinate value.']] as ParseResults; + } + + let { bear, deg, min, sec } = arg; + + deg ??= '0'; + // NOTE: need `||=` not `??=` because empty-string is not nullish + min ||= '0'; + sec ||= '0'; + + let isNegative: 0 | 1 = SYMBOL_PATTERNS.NEGATIVE_SIGN.test(deg) ? 1 : 0; + const bearingOptions = BEARINGS[format][i] as [string, string]; + + if (!bear || isNegative) { + bear = bearingOptions[isNegative]; + deg = Math.abs(Number.parseFloat(deg)).toString(); + isNegative = 0; + } + + const errors = [ + ...[checks.deg(deg, LIMITS[format][i] ?? 0)], + ...[checks.min(min)], + ...[checks.sec(sec)], + ].filter(Boolean); + + return ( + errors.length ? [[], errors] : [[deg, min, sec, bear], []] + ) as ParseResults; + }; +} + +function identifyPieces(half: string[]) { + if (half.length < 1 || half.length > 4) { + return; + } + + const asString = half.join(' '); + const places = { bear: '', deg: '', min: '', sec: '' }; + const keys = ['deg', 'min', 'sec'] as (keyof typeof places)[]; + const test = (r: RegExp, b: boolean, v: string) => + r.test(v) || (r.test(asString) && b); + + return half.reduce((acc, token, i, { length }) => { + if (test(SYMBOL_PATTERNS.NSEW, i === length - 1, token)) { + acc.bear ||= token; + } else if (test(SYMBOL_PATTERNS.DEGREES, i === 0, token)) { + acc.deg ||= token; + } else if (test(SYMBOL_PATTERNS.MINUTES, i === 1, token)) { + acc.min ||= token; + } else if (test(SYMBOL_PATTERNS.SECONDS, i === 2, token)) { + acc.sec ||= token; + } else { + const key = keys.find((k) => !acc[k]); + + acc[key as keyof typeof acc] = token; + } + + return acc; + }, places); +} + +/** Parse a Degrees Minutes Seconds coordinate. */ +export const parseDegreesMinutesSeconds = createParser({ + formats, + identifyErrors, + identifyPieces, +}); diff --git a/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/system.ts b/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/system.ts new file mode 100644 index 00000000..b0182969 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/degrees-minutes-seconds/system.ts @@ -0,0 +1,55 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + BEARINGS, + type Compass, + type Format, + SYMBOL_PATTERNS, + SYMBOLS, +} from '../internal'; +import type { CoordinateSystem } from '../internal/coordinate-sytem'; + +import { parseDegreesMinutesSeconds } from './parser'; + +type ToFloat = [string, string, string, Compass]; + +export const systemDegreesMinutesSeconds: CoordinateSystem = { + name: 'Degrees Minutes Seconds', + + parse: parseDegreesMinutesSeconds, + + toFloat: ([degrees, minutes, seconds, bear]) => + Number.parseFloat( + ( + (Number.parseFloat(degrees) + + Number.parseFloat(minutes) / 60 + + Number.parseFloat(seconds) / 3600) * + (SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(bear) ? -1 : 1) + ).toFixed(9), + ), + + toFormat: (format: Format, [left, right]: [number, number]) => { + return [left, right] + .map((num, index) => { + const abs = Math.abs(num); + const deg = Math.floor(abs); + const rem = (abs - deg) * 60; + const min = Math.floor(rem); + const sec = Number.parseFloat(((rem - min) * 60).toFixed(9)); + + return `${deg} ${min} ${sec} ${BEARINGS[format][index as 0 | 1][+(num < 0)]}`; + }) + .join(` ${SYMBOLS.DIVIDER} `); + }, +}; diff --git a/packages/geo/src/coordinates/latlon/internal/coordinate-sytem.ts b/packages/geo/src/coordinates/latlon/internal/coordinate-sytem.ts new file mode 100644 index 00000000..f6571edb --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/coordinate-sytem.ts @@ -0,0 +1,25 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Format } from '.'; +import type { ParseResults } from './parse'; + +// NOTE: isolated CoordinateSystem type so that it could be a private-export + +// biome-ignore lint/suspicious/noExplicitAny: there must be a better way??? +export type CoordinateSystem = { + name: string; + parse: (format: Format, input: string) => ParseResults; + toFloat: (a: F) => number; + toFormat: (f: Format, a: [number, number]) => string; +}; diff --git a/packages/geo/src/coordinates/latlon/internal/create-cache.ts b/packages/geo/src/coordinates/latlon/internal/create-cache.ts new file mode 100644 index 00000000..2cdbd87d --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/create-cache.ts @@ -0,0 +1,35 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { FORMATS, SYMBOLS, type Format } from '.'; + +export type CoordinateCache = Record; + +const DIVIDER = ` ${SYMBOLS.DIVIDER} `; + +/** + * Create, and initialize, a cache object for coordinate conversions so that + * conversions are only ever done once and only "one-direction-ally". The + * "one-direction" concept is to avoid the problem of encountering rounding + * errors when converting between multiple formats. + * */ +export function createCache(format: Format, value: string) { + const [alternate] = FORMATS.filter((o) => o !== format) as [Format]; + + return { + [format]: value, + [alternate]: value.includes(SYMBOLS.DIVIDER) + ? value.split(DIVIDER).reverse().join(DIVIDER).trim() + : value, + } as CoordinateCache; +} diff --git a/packages/geo/src/coordinates/latlon/internal/exhaustive-errors.ts b/packages/geo/src/coordinates/latlon/internal/exhaustive-errors.ts new file mode 100644 index 00000000..ea60d835 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/exhaustive-errors.ts @@ -0,0 +1,105 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { cartesian } from '@/cartesian'; + +type Values = { + invalid: Record; + valid: Record; +}; + +const values: Values = { + invalid: { + BLAT: ['X', 'random garbage'], + BLON: ['X', 'random garbage'], + DLAT: ['91', 'nope'], + DDLAT: ['90.1', 'nope'], + DLON: ['181', 'nope'], + DDLON: ['180.1', 'nope'], + M: ['-1', '61', 'nope'], + MM: ['-0.1', '60.1', 'nope'], + SS: ['-0.1', '60.1', 'nope'], + }, + valid: { + '/': '/', + BLAT: 'N', + BLON: 'E', + DLAT: '89', + DDLAT: '89.999999999', + DLON: '179', + DDLON: '179.999999999', + M: '59', + MM: '59.999999999', + SS: '59.999999999', + }, +}; + +const systems = [ + { + designation: 'DD', + LAT: ['DDLAT', 'BLAT DDLAT', 'DDLAT BLAT'], + LON: ['DDLON', 'BLON DDLON', 'DDLON BLON'], + }, + { + designation: 'DDM', + LAT: ['DLAT MM', 'BLAT DLAT MM', 'DLAT MM BLAT'], + LON: ['DLON MM', 'BLON DLON MM', 'DLON MM BLON'], + }, + { + designation: 'DMS', + LAT: ['DLAT M SS', 'BLAT DLAT M SS', 'DLAT M SS BLAT'], + LON: ['DLON M SS', 'BLON DLON M SS', 'DLON M SS BLON'], + }, +]; + +/** + * A collection of input strings each with exactly one error in a unique + * position for each format (LATLON and LONLAT) in each system (DD, DDM, DMS). + */ +export const EXHAUSTIVE_ERRORS = Object.fromEntries( + systems.map(({ designation, ...system }) => { + // for both format options + const options = ['LAT LON', 'LON LAT'].map((format) => [ + // create object key: 'LATLON' or 'LONLAT' + format.replace(' ', ''), + + // cross-join each variation of LAT with each variation of LON in the system + cartesian( + ...format.split(' ').map((key) => system[key as keyof typeof system]), + ) + // input not including this isn't an error so no need for variation + .map((pair) => pair.join(' / ')) + // fill the generated template with actual values + .flatMap((t) => fillTemplate(t, values)), + ]); + + return [designation, Object.fromEntries(options)]; + }), +); + +function fillTemplate(template: string, values: Values) { + return template + .split(' ') + .flatMap((key, i, original) => { + if (!values.invalid[key]) { + return ''; + } + + return (values.invalid[key] as string[]).map((opt) => + [...original.slice(0, i), opt, ...original.slice(i + 1)] + .map((token) => (token in values.valid ? values.valid[token] : token)) + .join(' '), + ); + }) + .filter(Boolean); +} diff --git a/packages/geo/src/coordinates/latlon/internal/in-range.ts b/packages/geo/src/coordinates/latlon/internal/in-range.ts new file mode 100644 index 00000000..2164276d --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/in-range.ts @@ -0,0 +1,28 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Return an error string if the value is outside the range where the limits + * are 0-limit. + */ +export const inRange = (label: string, value: string, limit: number) => { + const num = Number.parseFloat(value); + + if (limit < num) { + return `${label} value (${value}) exceeds max value (${limit}).`; + } + + if (num < 0) { + return `${label} value (${value}) must be positive.`; + } +}; diff --git a/packages/geo/src/coordinates/latlon/internal/index.ts b/packages/geo/src/coordinates/latlon/internal/index.ts new file mode 100644 index 00000000..b994c005 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/index.ts @@ -0,0 +1,146 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import * as Patterning from '@/patterning'; + +export type Axes = 'LAT' | 'LON'; +export type Compass = 'N' | 'S' | 'E' | 'W'; +export type Errors = string[]; +export type Format = (typeof FORMATS)[number]; + +/** + * Bearings are the consistent/explicit identifiers of directionality of a + * coordinate component; this library has opted for these over implicit + * indication by number sign not because there is an inherent superiority + * but because something had to be chosen. + * + * NOTE: these arrays are position-important; negative values are [1] and + * positive values are [0] so that they can be consistently indexed using + * an `isNegative` boolean to reference the negative bearing of each axis + */ +export const BEARINGS = { + LAT: ['N', 'S'], + LON: ['E', 'W'], + LATLON: [ + ['N', 'S'], + ['E', 'W'], + ], + LONLAT: [ + ['E', 'W'], + ['N', 'S'], + ], +} as const; + +export const FORMATS = ['LATLON', 'LONLAT'] as const; +export const FORMATS_DEFAULT = FORMATS[0]; + +export const LIMITS = { LATLON: [90, 180], LONLAT: [180, 90] } as const; + +export const SYMBOLS = { + DEGREES: '°', + MINUTES: "'", + SECONDS: '"', + DIVIDER: '/', +}; + +export const SYMBOL_PATTERNS = { + LAT: new RegExp(`[${BEARINGS.LAT.join('')}]`), + LON: new RegExp(`[${BEARINGS.LON.join('')}]`), + NSEW: new RegExp(`[${[...BEARINGS.LAT, ...BEARINGS.LON].join('')}]`), + NEGATIVE_BEARINGS: /[SW]/i, + NEGATIVE_SIGN: /-/, + + DEGREES: new RegExp(SYMBOLS.DEGREES), + MINUTES: new RegExp(SYMBOLS.MINUTES), + SECONDS: new RegExp(SYMBOLS.SECONDS), + + DIVIDER: new RegExp(SYMBOLS.DIVIDER), + + DMS: new RegExp( + `[${[SYMBOLS.DEGREES, SYMBOLS.MINUTES, SYMBOLS.SECONDS].join('')}]`, + ), + + // divider: { + // first: /(?:?)/, + // follow: new RegExp(`\\s?\\k<${'NAMED_SEPARATOR'}>\\s?`), + // }, +} as const; + +const decimalSecAndMin = (symbol: RegExp) => + Patterning.optional( + // Negative lookbehind + // to ensure that the match is not preceded by a digit, + // avoiding partial matches within larger numbers. + /(?; + +/** + * Separating latitude from longitude portions of a coordinate. At this level + * of pattern matching this list can not include the "space" character since + * that is valid between components of either side of a divider; higher level + * parsers will be able to make up for this shortcoming and be more intelligent + * about deducing where a divider could be added or would not be valid. + */ +const DIVIDERS = /[,/]/g; +const FLOATS = /^(-?)([\d\.]+)([^.\d]?)$/; +/** Positional indicators for: degrees, minutes, and seconds */ +const POSITIONAL = new RegExp( + Patterning.merge(/\s*/, Patterning.capture(SYMBOL_PATTERNS.DMS), /\s*/), + 'g', +); +const POSITIVE = /\+/g; +const SIGNS = /([-+])\s*/g; +/** + * Any recognizably significant tokens anywhere (non-positional-y) within a + * string; because at this level (lexing) actual position is not important. + * + * - [Regex Vis](https://regex-vis.com/?r=%2F%5B%2C%2F%5D%7C%5BNSEW%5D%7C%28%3F%3A%5B-%2B%5D%3F%28%3F%3A%28%3F%3A%5Cd%2B%28%3F%3A%5C.%5Cd*%29%3F%29%7C%28%3F%3A%5C.%5Cd%2B%29%29%28%3F%3A%5B%C2%B0%27%22%5D%29%3F%29%2Fgi) + * - [Nodexr](https://www.nodexr.net/?parse=%2F%5B,%2F%5D%7C%5BNSEW%5D%7C(%3F%3A%5B-%2B%5D%3F(%3F%3A(%3F%3A%5Cd%2B(%3F%3A%5C.%5Cd*)%3F)%7C(%3F%3A%5C.%5Cd%2B))%5B%C2%B0%27%22%5D%3F)%2Fgi) + */ +// NOTE: the links (above) for "Regex Vis" and "Nodexr" would need to be updated if/when the pattern is changed. +const TOKENS = new RegExp( + Patterning.merge( + DIVIDERS, + /|/, + SYMBOL_PATTERNS.NSEW, + /|/, + Patterning.group( + /[-+]?/, + Patterning.group( + // left of decimal REQUIRED, right of decimal optional + /(?:\d+(?:\.\d*)?)|/, + // left of decimal omitted, right of decimal REQUIRED + /(?:\.\d+)/, + ), + Patterning.optional(SYMBOL_PATTERNS.DMS), + ), + ), + 'gi', +); + +// remove trailing zeros '?.0' and ensure leading zero '0.?' in numbers +function fixLeadingAndTrailing(t: string) { + const [sign, num, pos] = (FLOATS.exec(t) ?? []).slice(1); + + if (num) { + return `${sign}${Number.parseFloat(num)}${pos}`; + } + + return t; +} + +/** + * Take an input string - possibly from user input - and clean it up enough to + * be something to work with at a higher level of processing (with more + * information) than is available at this level. Generating a list of "tokens" + * that are potentially valid parts of a coordinate. The values being looked + * for are: numbers (with positional indicators) and axes (NSEW). + * + * NOTE: No validation is done at this level to keep it simple as agnostic. + * + * @pure + * + * @example + * lexer('N 55,E 44') === ['N' '55', '/', 'E', '44'] + * lexer(` + 89 ° 59 59.999 " N, 179° 59 59.999" `) === ['89', '59', '59.999', 'N', '/', '179', '59', '59.999', 'E'] + */ +export function lexer(input: string) { + const tokens = + input + .trim() + .toUpperCase() + .replace(POSITIVE, '') // positive signs are redundant + .replace(POSITIONAL, '$1 ') // group positional indicators with numbers + .replace(SIGNS, '$1') // group signs with numbers + .replace(DIVIDERS, SYMBOLS.DIVIDER) // standardize the divider + .match(TOKENS) + ?.map(fixLeadingAndTrailing) + ?.slice() ?? []; + + return tokens; +} diff --git a/packages/geo/src/coordinates/latlon/internal/parse-format.ts b/packages/geo/src/coordinates/latlon/internal/parse-format.ts new file mode 100644 index 00000000..977aced3 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/parse-format.ts @@ -0,0 +1,102 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { type Format, SYMBOL_PATTERNS, SYMBOLS } from '.'; +import { type ParseResults, parse } from './parse'; +import { violation } from './violation'; + +type FormatParserConfig = { + formats: { + // biome-ignore lint/style/useNamingConvention: + LATLON: RegExp; + // biome-ignore lint/style/useNamingConvention: + LONLAT: RegExp; + }; + identifyErrors: (format: Format) => (arg: T, i: number) => ParseResults; + identifyPieces: (half: string[]) => T; +}; + +const axisTypeTest = (k: keyof typeof SYMBOL_PATTERNS, t: string) => + SYMBOL_PATTERNS[k].test(t) && k; + +export const createParser = + (config: FormatParserConfig) => + (format: Format, input: string) => + parseWithConfig(config, format, input); + +function parseWithConfig( + config: FormatParserConfig, + format: Format, + input: string, +): ParseResults { + const [parseTokens, violations] = parse(input, format); + + const foundFormat = parseTokens.reduce( + (acc, t) => acc + (axisTypeTest('LAT', t) || axisTypeTest('LON', t) || ''), + '', + ); + + if (!violations.length && foundFormat && foundFormat !== format) { + return [ + [], + [ + violation( + `Mismatched formats: "${format}" expected, "${foundFormat}" found.`, + ), + ], + ]; + } + + const hasErrors = !parseTokens.length && violations.length; + + if (hasErrors) { + return [[], violations]; + } + + const [tokens, errors] = [ + parseTokens.slice(0, parseTokens.indexOf(SYMBOLS.DIVIDER)), // left of divider + parseTokens.slice(1 + parseTokens.indexOf(SYMBOLS.DIVIDER)), // right of divider + ] + .map(config.identifyPieces) + .map(config.identifyErrors(format)) + .reduce((a, b) => [ + [...a[0], SYMBOLS.DIVIDER, ...b[0]], + [...a[1], ...b[1]].map(violation), + ]); + + if (errors.length) { + return [ + [], + // dedupe errors + Array.from(new Set(errors)), + ]; + } + + const matches = (config.formats[format].exec(tokens.join(' ')) || []).slice( + 1, + ); + + if (!matches?.length) { + throw new Error( + [ + 'A validation check seems to be missing because the cleaned and normalized input value (', + tokens.join(' '), + ') is not matching the pattern (', + config.formats[format], + ').', + ].join(''), + ); + } + + return [matches, []]; +} diff --git a/packages/geo/src/coordinates/latlon/internal/parse.test.ts b/packages/geo/src/coordinates/latlon/internal/parse.test.ts new file mode 100644 index 00000000..a20bacd8 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/parse.test.ts @@ -0,0 +1,68 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, it, describe } from 'vitest'; + +import { parse } from './parse'; + +describe('parse', () => { + it.each` + input | tokens | errors + ${'0 0'} | ${'0 / 0'} | ${[]} + ${'.01 .02'} | ${'0.01 / 0.02'} | ${[]} + ${'-.03 +.04'} | ${'-0.03 / 0.04'} | ${[]} + ${'-3.0 +4.0'} | ${'-3 / 4'} | ${[]} + ${'4/5'} | ${'4 / 5'} | ${[]} + ${'N 44 / E 22'} | ${'44 N / 22 E'} | ${[]} + ${'1 2 N 3 4 5 E'} | ${'1 2 N / 3 4 5 E'} | ${[]} + ${'1 2 0 N E 3 4 '} | ${'1 2 0 N / 3 4 E'} | ${[]} + ${'N 0 0 E'} | ${'0 N / 0 E'} | ${[]} + ${'1 2 3 4 5 6'} | ${'1 2 3 / 4 5 6'} | ${[]} + ${'-1 -1'} | ${'-1 / -1'} | ${[]} + ${'1 2 3 4 5 6 E'} | ${'1 2 3 N / 4 5 6 E'} | ${[]} + ${'N 1 2 3 4 5 6'} | ${'1 2 3 N / 4 5 6 E'} | ${[]} + ${'N 10 -20'} | ${'10 N / 20 W'} | ${[]} + ${'1 2 N'} | ${'1 E / 2 N'} | ${[]} + ${'10 N -20'} | ${'10 N / 20 W'} | ${[]} + ${'1 N 2'} | ${'1 N / 2 E'} | ${[]} + ${'1 E 2'} | ${'1 E / 2 N'} | ${[]} + ${'1 2 3 E 4'} | ${'1 2 3 E / 4 N'} | ${[]} + ${'12 ° 56 " 12° 56"'} | ${'12° 56" / 12° 56"'} | ${[]} + ${''} | ${''} | ${['[ERROR] No input.']} + ${'0'} | ${''} | ${['[ERROR] Too few numbers.']} + ${'N 0 0 0 0 0'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${'0 0 0 0 0 N'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${'0 0 0 0'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${'N 1 2 3 E'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${'1 2 3 4 E 5'} | ${''} | ${['[ERROR] Too many numbers.']} + ${'1 E 2 3 4 5'} | ${''} | ${['[ERROR] Too many numbers.']} + ${'1 2 3 4 5 6 7'} | ${''} | ${['[ERROR] Too many numbers.']} + ${`4° 5° / 6° 7'`} | ${''} | ${['[ERROR] Too many degrees indicators.']} + ${`4 5' / 6' 7'`} | ${''} | ${['[ERROR] Too many minutes indicators.']} + ${`4 5" / 6" 7"`} | ${''} | ${['[ERROR] Too many seconds indicators.']} + ${'N + 40 S/E - 74.12342 W'} | ${''} | ${['[ERROR] Too many bearings.']} + ${'N -10 -10'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'N -10 E -10'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'-10 N E -10'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'N -10 -10 E'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'-10 N -10 E'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'-10 0 0 N -10 E'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'-10 N -10 0 0 E'} | ${''} | ${['[ERROR] Bearing (N) conflicts with negative number (-10).']} + ${'-10 S / -10 E'} | ${''} | ${['[ERROR] Bearing (E) conflicts with negative number (-10).']} + `('parse($input)', ({ input, ...expected }) => { + const [tokens, errors] = parse(input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); +}); diff --git a/packages/geo/src/coordinates/latlon/internal/parse.ts b/packages/geo/src/coordinates/latlon/internal/parse.ts new file mode 100644 index 00000000..b88167c6 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/parse.ts @@ -0,0 +1,62 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Errors, Format } from '.'; +import { lexer, type Tokens } from './lexer'; +import { pipesRunner } from './pipes'; +import { violation } from './violation'; + +export type ParseResults = [Tokens, Errors]; + +/** + * Parse a raw input string into a validated and normalized coordinate + * primitive ready for further refinement/validation by a more specific parser. + * + * @param input + * raw input, from a user or external system, of unknown validity + * + * @param format + * the expected format - LATLON or LONLAT - the coordinate should conform to + * + * @returns + * tuple with two values: tokens, and errors + * + * @pure + * + * @example + * const input = '1 2 3 / 4 5 6' + * parse(input); // [['1', '2', '3', '/', '4', '5', '6'], []] + * + * @description + * __Assumptions/Specification__ + * 1. Decimals are indicated by a "." and not "," + * 2. A degrees indicator is "°" + * 3. A minutes indicator is "'" + * 4. A seconds indicator is '"' + * 5. Each indicator is expected to follow the number it is annotating + * 6. Numeric parts - degrees, minutes, and seconds - are positional and can + * not be arranged in alternative orders and be considered valid + * 7. Output will have explicit bearings characters (NSEW) and not rely on + * positive and negative values; when bearings are available. Negative + * values will only be replaced with absolute values if bearings are available. + * + */ +export function parse(input: string, format?: Format): ParseResults { + if (!input?.length) { + return [[], [violation('No input.')]]; + } + + const [tokens, errors] = pipesRunner(lexer(input), format); + + return [tokens, errors?.length ? errors.map(violation) : []]; +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/check-ambiguous.ts b/packages/geo/src/coordinates/latlon/internal/pipes/check-ambiguous.ts new file mode 100644 index 00000000..0ea85bce --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/check-ambiguous.ts @@ -0,0 +1,57 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { SYMBOL_PATTERNS, SYMBOLS, type Format } from '..'; +import type { Tokens } from '../lexer'; +import { pipesResult } from '../pipes'; + +import { simpler } from './simpler'; + +/** + * Look for groupings of numbers that are ambiguous; no indicators, or no + * dividers and not possibility of deducing where a divider should be inserted. + */ +export function checkAmbiguousGrouping(tokens: Tokens, _format?: Format) { + if (tokens.includes(SYMBOLS.DIVIDER)) { + return pipesResult(tokens, false); + } + + const simple = simpler(tokens); + + // 3-5 numbers after or before a single bearing indicator BNNN, NNNB + const ambiguous = /^(?:(?:BN{3,5})|(?:N{3,5}B))$/.test(simple); + // 3-5 numbers between 2 bearings indicators BNNNB + const bookends = /^BN{3,5}B$/.test(simple); + // "helpful" indicators: + // 1. degrees number in the right-of-divider position e.g. # #° + // 2. seconds number in the left-of-divider position e.g. # #" # + // 3. trailing bearings in the left-of-divider position e.g. # N # + // 4. leading bearings in the right-of-divider position e.g. N # E # + const helpful = tokens + // helpful tokens are only helpful in the middle of the token list + // not the beginning or end of the list + .slice(1, -1) + .reduce((acc, t) => { + const helps = + t.includes(SYMBOLS.DEGREES) || + t.includes(SYMBOLS.SECONDS) || + SYMBOL_PATTERNS.NSEW.test(t); + + return `${acc}${helps ? 'H' : '_'}`; + }, '') + .includes('H'); + // 3-5 numbers with no bearings indicators, and no helpful indicators + const hopeless = /^N{3,5}$/.test(simple) && !helpful; + + return pipesResult(tokens, ambiguous || bookends || hopeless); +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/check-numbers.ts b/packages/geo/src/coordinates/latlon/internal/pipes/check-numbers.ts new file mode 100644 index 00000000..2576274c --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/check-numbers.ts @@ -0,0 +1,60 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Tokens } from '../lexer'; +import { pipesResult } from '../pipes'; + +import { simpler } from './simpler'; + +/** + * Check for problems in the numeric values. + */ +export function checkNumberValues(tokens: Tokens) { + const simple = simpler(tokens); + + if ((simple.match(/N/g) ?? []).length < 2) { + return pipesResult(tokens, 'Too few numbers.'); + } + + const error = + // 4 consecutive numbers in specific formation is not going to be valid + /(?:N{4,}BN+)|(?:N+BN{4,})/.test(simple) || + // more than 6 numbers total + (simple.match(/N/g) ?? []).length > 6; + + if (error) { + return pipesResult(tokens, 'Too many numbers.'); + } + + const pattern = tokens + .reduce((acc, t) => { + if (/\d/.test(t)) { + acc.push(Number.parseFloat(t) < 0 ? '-' : '+'); + } else { + acc.push('_'); + } + + return acc; + }, [] as string[]) + .join(''); + + const matches = pattern.match(/[^_]-./); + + // special case '_--_' when the input is something like 'S -1 -1 W' + // which is invalid for other reasons and will be caught elsewhere + if (!!matches && pattern !== '_--_') { + return pipesResult(tokens, 'Negative value for non-degrees value found.'); + } + + return pipesResult(tokens, false); +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/fix-bearings.ts b/packages/geo/src/coordinates/latlon/internal/pipes/fix-bearings.ts new file mode 100644 index 00000000..a5ac39bf --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/fix-bearings.ts @@ -0,0 +1,114 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { BEARINGS, SYMBOL_PATTERNS, SYMBOLS, type Format } from '..'; +import type { Tokens } from '../lexer'; +import type { PipeResult } from '../pipes'; + +const orthogonal = { + N: BEARINGS.LON, + S: BEARINGS.LON, + E: BEARINGS.LAT, + W: BEARINGS.LAT, +}; + +const bearingConflictsWithNumber = (tokens: Tokens) => + tokens[0] && + tokens[1] && + SYMBOL_PATTERNS.NEGATIVE_SIGN.test(tokens[1]) && + SYMBOL_PATTERNS.NSEW.test(tokens[0]) && + !SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(tokens[0]); + +const bePositive = (n: string) => n.replace(SYMBOL_PATTERNS.NEGATIVE_SIGN, ''); + +const conflict = ([a, b]: Tokens) => + `Bearing (${a}) conflicts with negative number (${b}).`; + +/** + * Normalize bearings - negative and positive numeric values to NSEW - and + * positioning of bearings - after the numeric values - and fill in any missing + * bearings if only one is provided. + */ +export function fixBearings(tokens: Tokens, format?: Format): PipeResult { + const [left, right] = [ + tokens.slice(0, tokens.indexOf(SYMBOLS.DIVIDER)), + tokens.slice(1 + tokens.indexOf(SYMBOLS.DIVIDER)), + ].map(moveBearingsToHead) as [Tokens, Tokens]; + + if (bearingConflictsWithNumber(left)) { + return [[], conflict(left)]; + } + + if (bearingConflictsWithNumber(right)) { + return [[], conflict(right)]; + } + + const [leftHasBearing, rightHasBearing] = [left, right].map( + (list) => !!(list?.[0] && SYMBOL_PATTERNS.NSEW.test(list[0])), + ); + + let leftBearing = ''; + let rightBearing = ''; + + if (leftHasBearing && rightHasBearing) { + leftBearing = left.shift() ?? ''; + rightBearing = right.shift() ?? ''; + } else if (leftHasBearing) { + leftBearing = left.shift() ?? ''; + rightBearing = + orthogonal[leftBearing as keyof typeof orthogonal][ + +SYMBOL_PATTERNS.NEGATIVE_SIGN.test(right[0] ?? '') as 0 | 1 + ]; + } else if (rightHasBearing) { + rightBearing = right.shift() ?? ''; + leftBearing = + orthogonal[rightBearing as keyof typeof orthogonal][ + +SYMBOL_PATTERNS.NEGATIVE_SIGN.test(right[0] ?? '') as 0 | 1 + ]; + } else if (format) { + leftBearing = `${BEARINGS[format][0][+SYMBOL_PATTERNS.NEGATIVE_SIGN.test(`${left[0]}`)]}`; + rightBearing = `${BEARINGS[format][1][+SYMBOL_PATTERNS.NEGATIVE_SIGN.test(`${right[0]}`)]}`; + } else { + // neither exist + return [[...left, SYMBOLS.DIVIDER, ...right], false]; + } + + return [ + [ + ...left.map(bePositive), + leftBearing, + SYMBOLS.DIVIDER, + ...right.map(bePositive), + rightBearing, + ], + false, + ]; +} + +/** + * Move the bearings indicators to the first element in the list - in this + * module only - so that it is easier to work with; moving a bearing to the + * "head" allows for `push()` of subsequent number processing will keep the + * order of the numeric values intact. + */ +function moveBearingsToHead(coord: Tokens) { + return coord.reduce((acc, t) => { + if (/\d/.test(t)) { + acc.push(t); + } else { + acc.unshift(t); + } + + return acc; + }, [] as Tokens); +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/fix-dividers.ts b/packages/geo/src/coordinates/latlon/internal/pipes/fix-dividers.ts new file mode 100644 index 00000000..cb100c47 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/fix-dividers.ts @@ -0,0 +1,65 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { SYMBOLS, type Format } from '..'; +import type { Tokens } from '../lexer'; +import type { PipeResult } from '../pipes'; +import { getGenomeIndex } from './genome'; +import { simpler } from './simpler'; + +// N = number +// B = bearing +const SIMPLER_PATTERNS = { + NN: 1, + NNB: 1, + BNNB: 2, + BNN: 2, +}; + +const insertDivider = (tokens: Tokens, index: number): PipeResult => [ + [...tokens.slice(0, index), SYMBOLS.DIVIDER, ...tokens.slice(index)], + false, +]; + +/** + * For tokens lists without a divider, `fixDivider` attempts to determine the + * __safe__ location to add a divider based on the existing formatting of the + * coordinate: numbers, number positions, and number indicators. + * + * @pure + */ +export function fixDivider(original: Tokens, _format?: Format): PipeResult { + // if there is already a divider then there is nothing to do + if (original.includes(SYMBOLS.DIVIDER)) { + return [original, false]; + } + + // disconnect from argument memory space so we aren't working on shared memory + const tokens = original.slice(0); + + const genomeIndex = getGenomeIndex(tokens); + + if (genomeIndex) { + return insertDivider(tokens, genomeIndex); + } + + const simple = simpler(tokens) as keyof typeof SIMPLER_PATTERNS; + + if (SIMPLER_PATTERNS[simple]) { + return insertDivider(tokens, SIMPLER_PATTERNS[simple]); + } + + // no position is found to be a safe location to insert a divider; any placement + // would be a guess and therefor only has a 50% chance of being wrong or right + return [[], true]; +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/genome.ts b/packages/geo/src/coordinates/latlon/internal/pipes/genome.ts new file mode 100644 index 00000000..4eefa53b --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/genome.ts @@ -0,0 +1,80 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { SYMBOL_PATTERNS, SYMBOLS } from '..'; +import type { Tokens } from '../lexer'; + +const GENOME_PATTERN = + /^(B?)([DN]?[MN]?[SN]?)(B?)(?:B?)([DN]?[MN]?[SN]?)(?:B?)$/; + +/** + * Get the position (index) of where to insert a divider into the token list; + * basically, the count of numeric components (left-of-divider position) plus + * 1 if there is a bearing identifier (left-of-divider). + */ +function dividerIndexer(_full: string, ...args: string[]) { + const [bearing1 = '', number1, bearing2 = '', number2] = args; + + // if no numeric values exist there no way to infer a location to insert a divider + if (!(number1?.length && number2?.length)) { + return '0'; + } + + return `${number1.length + (bearing1.length || bearing2.length)}`; +} + +/** + * The genome sequence is a simplification of the tokens list: + * + * - B = bearings (NSEW) + * - D = degrees (number with degree character following) + * - M = minutes (number with minutes character following) + * - S = seconds (number with seconds character following) + * - N = number (no identifying character following) + * - X = for unmatched token types + */ +function genomeSequencer(acc: string, t: string) { + if (t.includes(SYMBOLS.DEGREES)) { + return `${acc}D`; + } + + if (t.includes(SYMBOLS.MINUTES)) { + return `${acc}M`; + } + + if (t.includes(SYMBOLS.SECONDS)) { + return `${acc}S`; + } + + if (SYMBOL_PATTERNS.NSEW.test(t)) { + return `${acc}B`; + } + + if (/\d/.test(t)) { + return `${acc}N`; + } + + return `${acc}X`; +} + +/** + * Use the "genome" sequence of the token list to find the index for inserting + * a missing divider token. + */ +export function getGenomeIndex(tokens: Tokens) { + const seq = tokens.reduce(genomeSequencer, ''); + + return GENOME_PATTERN.test(seq) + ? Number.parseInt(seq.replace(GENOME_PATTERN, dividerIndexer)) + : 0; +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/index.ts b/packages/geo/src/coordinates/latlon/internal/pipes/index.ts new file mode 100644 index 00000000..ead74bcb --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/index.ts @@ -0,0 +1,92 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { SYMBOL_PATTERNS, type Format } from '..'; +import type { Tokens } from '../lexer'; + +import { checkAmbiguousGrouping } from './check-ambiguous'; +import { checkNumberValues } from './check-numbers'; +import { fixBearings } from './fix-bearings'; +import { fixDivider } from './fix-dividers'; + +type Pipe = (t: Tokens, f?: Format) => [Tokens, boolean | string]; + +export type PipeResult = ReturnType; + +/** Make a RegExp global. */ +const makeGlobal = (k: keyof typeof SYMBOL_PATTERNS) => + new RegExp(SYMBOL_PATTERNS[k], 'g'); + +/** + * Consistently create a PipesResult array to return. Use this instead of + * casting to PipesResult everywhere. + * + * @param e true = has error, false = no error + * + * @pure + */ +export const pipesResult = (t: Tokens, e: boolean | string): PipeResult => [ + // if there are errors do NOT return the tokens + e ? [] : t, + e, +]; + +/** Check if there are more than 2 of something. */ +const tooMany = (p: RegExp) => (t: Tokens) => + pipesResult(t, (t.join('').match(p) ?? []).length > 2); + +const pipes: [string, Pipe][] = [ + // Unrecoverable violations + ['Too many bearings.', tooMany(makeGlobal('NSEW'))], + ['Too many numeric signs.', tooMany(/[-+]/g)], + ['Too many degrees indicators.', tooMany(makeGlobal('DEGREES'))], + ['Too many minutes indicators.', tooMany(makeGlobal('MINUTES'))], + ['Too many seconds indicators.', tooMany(makeGlobal('SECONDS'))], + ['Number values checks.', checkNumberValues], + ['Ambiguous grouping of numbers with no divider.', checkAmbiguousGrouping], + + // fix values and formatting to be consistent + ['Unable to identify latitude from longitude.', fixDivider], + ['Unable to identify bearings.', fixBearings], +]; + +/** + * Run the tokens through a preset pipeline of violations checks exiting the + * process as early as possible when violations are found because violations + * will make further violations checks less accurate and could return inaccurate + * violations that could be misleading or hide the most important violation + */ +export function pipesRunner( + tokens: Tokens, + format?: Format, +): [Tokens, string[]] { + let copy = tokens.slice(0); + let error: PipeResult[1] = false; + const errors = [] as string[]; + + for (const [message, op] of pipes) { + [copy, error] = op(copy, format); + + if (error) { + // accumulate the "errors" because if tokens are returned + // the errors are only warnings and are recoverable + errors.push(error === true ? message : error); + + if (!copy.length) { + return [copy, [error === true ? message : error]]; + } + } + } + + return [copy, errors]; +} diff --git a/packages/geo/src/coordinates/latlon/internal/pipes/simpler.ts b/packages/geo/src/coordinates/latlon/internal/pipes/simpler.ts new file mode 100644 index 00000000..6ba87c73 --- /dev/null +++ b/packages/geo/src/coordinates/latlon/internal/pipes/simpler.ts @@ -0,0 +1,26 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Tokens } from '../lexer'; + +/** + * Create a simplified pattern string - numbers = 'N', bearings = 'B' - to + * allow for simpler pattern matching. + * + * @pure + * + * @example + * simplify(tokens); // 'NNNBNNNB' or similar + */ +export const simpler = (tokens: Tokens) => + tokens.map((t) => (/\d/.test(t) ? 'N' : 'B')).join(''); diff --git a/packages/geo/src/coordinates/index.ts b/packages/geo/src/coordinates/latlon/internal/violation.ts similarity index 69% rename from packages/geo/src/coordinates/index.ts rename to packages/geo/src/coordinates/latlon/internal/violation.ts index 2f1e72b1..030c8942 100644 --- a/packages/geo/src/coordinates/index.ts +++ b/packages/geo/src/coordinates/latlon/internal/violation.ts @@ -1,3 +1,4 @@ +// __private-exports /* * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); @@ -10,15 +11,4 @@ * governing permissions and limitations under the License. */ -/** - * TODOs: - * - * - Auto detect lon/lat ordering? - * - Some tricks we can - * - Regex for NS as first few chars - * - Regex for 3 digits - * - Regex for first digit > 90 - */ - -export { matchDD, matchDMS } from './match'; -export { normalizeDecimalDegree } from './normalize'; +export const violation = (s: string) => `[ERROR] ${s}`; diff --git a/packages/geo/src/coordinates/match.test.ts b/packages/geo/src/coordinates/match.test.ts deleted file mode 100644 index d810c0d5..00000000 --- a/packages/geo/src/coordinates/match.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { expect, it, describe } from 'vitest'; -import { dd, dms } from './configurations'; -import { ddPairs, dmsPairs } from './__fixtures__'; -import { matchDD, matchDMS } from './match'; - -console.log(dd[0]); -console.log(dms[0]); - -describe('coordinates', () => { - describe('matching', () => { - describe('decimal degrees', () => { - for (const pairs of ddPairs) { - it(`${pairs[0]}: ${pairs[1]}`, () => { - const matches = matchDD(pairs[1]); - - expect(matches).toMatchSnapshot(); - }); - } - }); - }); - - describe('degrees minutes seconds', () => { - for (const pairs of dmsPairs) { - it(`${pairs[0]}: ${pairs[1]}`, () => { - const matches = matchDMS(pairs[1]); - - expect(matches).toMatchSnapshot(); - }); - } - }); -}); diff --git a/packages/geo/src/coordinates/match.ts b/packages/geo/src/coordinates/match.ts deleted file mode 100644 index d1183b6d..00000000 --- a/packages/geo/src/coordinates/match.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { dd, dms } from './configurations'; - -type Nullish = T | undefined | null | ''; -type PlusMinus = '+' | '-'; -type Directions = 'N' | 'S' | 'E' | 'W'; - -// biome-ignore lint/style/useNamingConvention: -type DDMatches = [ - Nullish, - Nullish, - Nullish, // degrees - Nullish, // decimals - Nullish, - Nullish, - Nullish, - Nullish, // degrees - Nullish, // decimals - Nullish, -]; - -const ddFallback = Array.from(Array(10), () => ''); - -// biome-ignore lint/style/useNamingConvention: -export function matchDD(val: unknown): DDMatches { - // TODO: lat/lon ordering - - const matches = `${val}`.match(dd[0]); - - if (!matches) { - return ddFallback as DDMatches; - } - - // Remove first regex.match entry since it is the input value - return Array.from(matches).slice(1) as DDMatches; -} - -// biome-ignore lint/style/useNamingConvention: -type DMSMatches = [ - Nullish, - Nullish, - Nullish, // degrees - Nullish, // minutes - Nullish, // seconds - Nullish, // milliarcseconds - Nullish, - Nullish, - Nullish, - Nullish, // degrees - Nullish, // minutes - Nullish, // seconds - Nullish, // milliarcseconds - Nullish, -]; - -const dmsFallback = Array.from(Array(14), () => ''); - -// biome-ignore lint/style/useNamingConvention: -export function matchDMS(val: unknown): DMSMatches { - // TODO: lat/lon ordering - - const matches = `${val}`.match(dms[0]); - - if (!matches) { - return dmsFallback as DMSMatches; - } - - // Remove first regex.match entry since it is the input value - return Array.from(matches).slice(1) as DMSMatches; -} diff --git a/packages/geo/src/coordinates/mgrs/parser.ts b/packages/geo/src/coordinates/mgrs/parser.ts new file mode 100644 index 00000000..37b40849 --- /dev/null +++ b/packages/geo/src/coordinates/mgrs/parser.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { MGRS } from '@ngageoint/mgrs-js'; + +import { parse, type ParseResults } from '../latlon/internal/parse'; +import { violation } from '../latlon/internal/violation'; + +type Match = [string, string, string, string, string]; + +const PATTERN_PARTS = + /^((?:..?)?)(\w?)\s*((?:\w{2})?)\s*(?:(\d+(?:\.\d*)?)?)\s*(?:(\d+(?:\.\d*)?)?)$/i; + +const error = (message: string) => + [ + [], + [`${violation(message)}; expected format DDZ AA DDD DDD.`], + ] as ParseResults; + +function detailedErrors(input: string) { + if (!input) { + return error('No input provided'); + } + + const [utm, bnd, hkm, east, north] = ( + input.trim().replace(/\s+/g, ' ').match(PATTERN_PARTS) ?? [] + ).slice(1) as Match; + + if (!utm || +utm > 60 || +utm < 1) { + return error( + `Invalid UTM zone number (${utm}) found in grid zone designation`, + ); + } + + if (!/[C-HJ-NP-X]/i.test(bnd)) { + return error( + `Invalid Latitude band letter (${bnd}) found in grid zone designation`, + ); + } + + if (!/^[A-HJ-NP-Z]*$/i.test(hkm)) { + return error(`Invalid 100K m square identification (${hkm}) found`); + } + + if (!(east && north && +east > 0 && +north > 0)) { + return error(`Invalid numerical location (${[east, north].join()}) found`); + } + + return error('Uncaught error condition.'); +} + +// biome-ignore lint/style/useNamingConvention: +// biome-ignore lint/suspicious/noExplicitAny: +export function parseMGRS(_format: any, input: string) { + try { + const point = MGRS.parse(input).toPoint(); + + return parse(`${point.getLatitude()} / ${point.getLongitude()}`, 'LATLON'); + } catch (_e) { + return detailedErrors(input); + } +} diff --git a/packages/geo/src/coordinates/mgrs/system.test.ts b/packages/geo/src/coordinates/mgrs/system.test.ts new file mode 100644 index 00000000..469fb0d5 --- /dev/null +++ b/packages/geo/src/coordinates/mgrs/system.test.ts @@ -0,0 +1,34 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, it, describe } from 'vitest'; + +import { parseMGRS } from './parser'; + +describe('MGRS', () => { + it.each` + input | tokens | errors + ${'30U WB 85358 69660'} | ${['51.1719929', 'N', '/', '1.779008', 'W']} | ${[]} + ${'4Q FJ 12345 67890'} | ${['21.4097968', 'N', '/', '157.9160812', 'W']} | ${[]} + ${'46T BQ 63553 87329'} | ${['44.9999953', 'N', '/', '89.9999879', 'E']} | ${[]} + ${'31N AA 66021 00000'} | ${['0', 'N', '/', '0.000004', 'W']} | ${[]} + ${'33T WN 08400 78900'} | ${['47.6634389', 'N', '/', '15.1118816', 'E']} | ${[]} + ${'15S WC 80800 17500'} | ${['38.1017007', 'N', '/', '92.0784341', 'W']} | ${[]} + ${'10S EG 57219 43918'} | ${['37.4403321', 'N', '/', '122.3531715', 'W']} | ${[]} + `('should parse $input', ({ input, ...expected }) => { + const [coord, errors] = parseMGRS('MGRS', input); + + expect(errors).toStrictEqual(expected.errors); + expect(coord).toStrictEqual(expected.tokens.map(String)); + }); +}); diff --git a/packages/geo/src/coordinates/mgrs/system.ts b/packages/geo/src/coordinates/mgrs/system.ts new file mode 100644 index 00000000..d43bdcc5 --- /dev/null +++ b/packages/geo/src/coordinates/mgrs/system.ts @@ -0,0 +1,46 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Point } from '@ngageoint/grid-js'; +import { MGRS } from '@ngageoint/mgrs-js'; + +import { SYMBOL_PATTERNS, type Format } from '../latlon/internal'; +import type { CoordinateSystem } from '../latlon/internal/coordinate-sytem'; + +import { parseMGRS } from './parser'; + +function toFormat([lat, lon]: [number, number]) { + const point3 = Point.point(lon, lat); + + return MGRS.from(point3).toString(); +} + +// biome-ignore lint/style/useNamingConvention: +export const systemMGRS: CoordinateSystem = { + name: 'Military Grid Reference System', + + parse: parseMGRS, + + toFloat: ([num, bear]) => + Number.parseFloat(num) * + (SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(bear) ? -1 : 1), + + toFormat: (format: Format, [left, right]: [number, number]) => { + const { LAT, LON } = Object.fromEntries([ + [format.slice(0, 3), left], + [format.slice(3), right], + ]) as Record<'LAT' | 'LON', number>; + + return toFormat([LAT, LON]); + }, +}; diff --git a/packages/geo/src/coordinates/normalize.ts b/packages/geo/src/coordinates/normalize.ts deleted file mode 100644 index 32328d4c..00000000 --- a/packages/geo/src/coordinates/normalize.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { round } from '@accelint/math'; -import { matchDD } from './match'; -import { isPositiveDirection, normalizeDirection, negate } from './utils'; - -export function normalizeDecimalDegree(val: string) { - const matches = matchDD(val); - - const latDirection = normalizeDirection(matches[0] || matches[4]); - const lonDirection = normalizeDirection(matches[5] || matches[9]); - const latPositive = isPositiveDirection(latDirection, matches[1]); - const lonPositive = isPositiveDirection(lonDirection, matches[6]); - - const latParsed = round(Number.parseFloat(`${matches[2]}${matches[3]}`), 6); - - const lonParsed = round(Number.parseFloat(`${matches[7]}${matches[8]}`), 6); - - const latValue = latPositive ? latParsed : negate(latParsed); - const lonValue = lonPositive ? lonParsed : negate(lonParsed); - - return [latValue, lonValue]; -} diff --git a/packages/geo/src/coordinates/regex.ts b/packages/geo/src/coordinates/regex.ts deleted file mode 100644 index c637642b..00000000 --- a/packages/geo/src/coordinates/regex.ts +++ /dev/null @@ -1,147 +0,0 @@ -// __private-exports - -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -// biome-ignore lint/style/noUnusedTemplateLiteral: makes it easier for future modifications -export const SEPARATORS = `[\\s,\\/]*`; - -/** - * @see https://en.wikipedia.org/wiki/ISO_6709 - * - * Matches common DD format for longitude values. - * - * NSEW ± DDD.D NSEW - * - * Five capture groups: - * 1. Optional NSEW - * 2. Optional +- - * 3. Degrees - * 4. Optional Decimals - * 5. Optional NSEW - * - * @example - * ```markdown - * 75 - * 75.00417 - * 075.00417 - * -075.00417 - * - 075.00417 - * W075.00417 - * 075.00417W - * W 075.00417 - * 075.00417 W - * ``` - */ -// biome-ignore lint/style/noUnusedTemplateLiteral: makes it easier for future modifications -export const DD_LON = `([NSEW]?)[\\s]*([-+]?)[\\s]*(\\d{1,3})(\\.\\d*)?[\\s°]*([NSEW]?)`; - -/** - * @see https://en.wikipedia.org/wiki/ISO_6709 - * - * Matches common DD format for latitude values. - * - * NSEW ± DD.D NSEW - * - * Five capture groups: - * 1. Optional NSEW - * 2. Optional +- - * 3. Degrees - * 4. Optional Decimals - * 5. Optional NSEW - * - * @example - * ```markdown - * 40 - * 40.20361 - * +40.20361 - * + 40.20361 - * N40.20361 - * 40.20361N - * N 40.20361 - * 40.20361 N - * ``` - */ -// biome-ignore lint/style/noUnusedTemplateLiteral: makes it easier for future modifications -export const DD_LAT = `([NSEW]?)[\\s]*([-+]?)[\\s]*(\\d{1,2})(\\.\\d*)?[\\s°]*([NSEW]?)`; - -/** - * @see https://en.wikipedia.org/wiki/ISO_6709 - * - * Matches common DMS format for longitude values. - * - * Supported delimiters are comma, colon, space, degree, single quote, double quote, and forward slash. - * - * NSEW ± DDD DD DD .D|DDD NSEW - * - * Seven capture groups: - * 1. Optional NSEW - * 2. Optional +- - * 3. Degrees - * 4. Minutes - * 5. Seconds - * 6. Optional decimal seconds or milliarcseconds - * 7. Optional NSEW - * - * @example - * ```markdown - * 403456.45 - * 40 34 56.45 - * 40/34/56.45 - * 40:34:56.45 - * 40°34'56.45 - * 403456 - * 40°34'56" - * +403456.45 - * W403456.45 - * 403456.45W - * W 40 34 56.45 - * 40 34 56.45 W - * ``` - */ -export const DMS_LON = `([NSEW]?)[\\s]*([-+]?)[\\s]*(\\d{1,3})[\\s,:°'′"″\\/]*(\\d{2})[\\s,:°'′"″\\/]*(\\d{2})[\\s,:°'′"″\\/]*(\\.\\d*|\\d{3})?[\\s,:°'′"″\\/]*([NSEW]?)`; - -/** - * @see https://en.wikipedia.org/wiki/ISO_6709 - * - * Matches common DMS format for latitude values. - * - * Supported delimiters are comma, colon, space, degree, single quote, double quote, and forward slash. - * - * NSEW ± DD DD DD .D|DDD NSEW - * - * Seven capture groups: - * 1. Optional NSEW - * 2. Optional +- - * 3. Degrees - * 4. Minutes - * 5. Seconds - * 6. Optional decimal seconds or milliarcseconds - * 7. Optional NSEW - * - * @example - * ```markdown - * 403456.45 - * 40 34 56.45 - * 40/34/56.45 - * 40:34:56.45 - * 40°34'56.45 - * 403456 - * 40°34'56" - * +403456.45 - * N403456.45 - * 403456.45N - * N 40 34 56.45 - * 40 34 56.45 N - * ``` - */ -export const DMS_LAT = `([NSEW]?)[\\s]*([-+]?)[\\s]*(\\d{1,2})[\\s,:°'′"″\\/]*(\\d{2})[\\s,:°'′"″\\/]*(\\d{2})[\\s,:°'′"″\\/]*(\\.\\d*|\\d{3})?[\\s,:°'′"″\\/]*([NSEW]?)`; diff --git a/packages/geo/src/coordinates/utils.ts b/packages/geo/src/coordinates/utils.ts deleted file mode 100644 index 7966a9d9..00000000 --- a/packages/geo/src/coordinates/utils.ts +++ /dev/null @@ -1,54 +0,0 @@ -// __private-exports - -/* - * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -type Nullish = T | undefined | null | ''; - -const supportedDirections = ['N', 'S', 'E', 'W']; - -export function normalizeDirection( - dir: Nullish, -): 'N' | 'S' | 'E' | 'W' | '' { - if (!dir) { - return ''; - } - - const val = dir.toUpperCase(); - - // Sanity check that the we have a correct direction value - if (!supportedDirections.includes(val)) { - return ''; - } - - return val as 'N' | 'S' | 'E' | 'W'; -} - -export function negate(val: number) { - return val * -1; -} - -export function isPositiveDirection( - dir: Nullish<'N' | 'S' | 'E' | 'W'>, - sign: Nullish<'+' | '-'>, -) { - if (sign === '-') { - return false; - } - - if (dir === 'S' || dir === 'W') { - return false; - } - - // A positive direction can be assumed from here - return true; -} diff --git a/packages/geo/src/coordinates/utm/parser.ts b/packages/geo/src/coordinates/utm/parser.ts new file mode 100644 index 00000000..6abbe8f8 --- /dev/null +++ b/packages/geo/src/coordinates/utm/parser.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { UTM } from '@ngageoint/mgrs-js'; + +import { parse, type ParseResults } from '../latlon/internal/parse'; +import { violation } from '../latlon/internal/violation'; + +type Match = [string, string, string, string]; + +const PATTERN_PARTS = + /^((?:..)?)\s*(\w?)\s*(?:(\d+(?:\.\d*)?)?)\s*(?:(\d+(?:\.\d*)?)?)$/i; + +const error = (message: string) => + [ + [], + [`${violation(message)}; expected format ZZ N|S DDD DDD.`], + ] as ParseResults; + +function detailedErrors(input: string) { + const [zone, band, east, north] = ( + input.trim().replace(/\s+/g, ' ').match(PATTERN_PARTS) ?? [] + ).slice(1) as Match; + + if (!zone || +zone > 60 || +zone < 1) { + return error(`Invalid Zone number (${zone}) found`); + } + + if (!/[NS]/i.test(band)) { + return error(`Invalid Latitude band letter (${band}) found`); + } + + if (!(east || +east >= 0)) { + return error(`Invalid Easting number (${east ?? ''}) found`); + } + + if (!(north || +north >= 0)) { + return error(`Invalid Northing number (${north ?? ''}) found`); + } + + return error('Uncaught error condition.'); +} + +// biome-ignore lint/style/useNamingConvention: +// biome-ignore lint/suspicious/noExplicitAny: +export function parseUTM(_format: any, input: string) { + try { + const point = UTM.parse(input).toPoint(); + + return parse(`${point.getLatitude()} / ${point.getLongitude()}`, 'LATLON'); + } catch (_) { + return detailedErrors(input); + } +} diff --git a/packages/geo/src/coordinates/utm/system.ts b/packages/geo/src/coordinates/utm/system.ts new file mode 100644 index 00000000..c08610cd --- /dev/null +++ b/packages/geo/src/coordinates/utm/system.ts @@ -0,0 +1,46 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Point } from '@ngageoint/grid-js'; +import { UTM } from '@ngageoint/mgrs-js'; + +import { SYMBOL_PATTERNS, type Format } from '../latlon/internal'; +import type { CoordinateSystem } from '../latlon/internal/coordinate-sytem'; + +import { parseUTM } from './parser'; + +function toFormat([lat, lon]: [number, number]) { + const point3 = Point.point(lon, lat); + + return UTM.from(point3).toString(); +} + +// biome-ignore lint/style/useNamingConvention: +export const systemUTM: CoordinateSystem = { + name: 'Military Grid Reference System', + + parse: parseUTM, + + toFloat: ([num, bear]) => + Number.parseFloat(num) * + (SYMBOL_PATTERNS.NEGATIVE_BEARINGS.test(bear) ? -1 : 1), + + toFormat: (format: Format, [left, right]: [number, number]) => { + const { LAT, LON } = Object.fromEntries([ + [format.slice(0, 3), left], + [format.slice(3), right], + ]) as Record<'LAT' | 'LON', number>; + + return toFormat([LAT, LON]); + }, +}; diff --git a/packages/geo/src/index.test.ts b/packages/geo/src/index.test.ts new file mode 100644 index 00000000..333469c3 --- /dev/null +++ b/packages/geo/src/index.test.ts @@ -0,0 +1,269 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, it, describe } from 'vitest'; + +import { + createCoordinate, + parseDecimalDegrees, + parseDegreesDecimalMinutes, + parseDegreesMinutesSeconds, + parseMGRS, +} from '.'; +import { coordinateSystems } from './coordinates/coordinate'; + +import { EXHAUSTIVE_ERRORS } from './coordinates/latlon/internal/exhaustive-errors'; +import type { Format } from './coordinates/latlon/internal'; +import { parseUTM } from './coordinates/utm/parser'; + +describe('creating a coordinate object', () => { + it.each` + system | format | input | dd | ddm | dms | mgrs + ${coordinateSystems.dd} | ${'LONLAT'} | ${'12.3456 E / 87.6543 N'} | ${'12.3456 E / 87.6543 N'} | ${'12 20.736 E / 87 39.258 N'} | ${'12 20 44.16 E / 87 39 15.48 N'} | ${'33XVT8787436351'} + ${coordinateSystems.ddm} | ${'LATLON'} | ${'11 33.02 N / 3 1.2 W'} | ${'3.02 W / 11.550333333 N'} | ${'3 1.2 W / 11 33.02 N'} | ${'3 1 12 W / 11 33 1.1999988 N'} | ${'30PVT9781976831'} + ${coordinateSystems.dms} | ${'LATLON'} | ${'11 22 33.44 N / 3 2 1.1 W'} | ${'3.033638889 W / 11.375955556 N'} | ${'3 2.01833334 W / 11 22.55733336 N'} | ${'3 2 1.1 W / 11 22 33.44 N'} | ${'30PVT9632957549'} + ${coordinateSystems.mgrs} | ${'LATLON'} | ${'30U WB 85358 69660'} | ${'1.779008 W / 51.1719929 N'} | ${'1 46.74048 W / 51 10.319574 N'} | ${'1 46 44.4288 W / 51 10 19.17444 N'} | ${'30U WB 85358 69660'} + `( + 'should create a coordinate in the $system.name system using the $format format', + ({ format, input, system, ...expected }) => { + const create = createCoordinate(system, format); + + const coord = create(input); + + expect(coord.valid).toBe(true); + + // explicit system and explicit format; needed because both will honor + // the values provided at time of instantiation without them here + expect(coord.dd('LONLAT')).toBe(expected.dd); + expect(coord.dd('LATLON')).toBe( + expected.dd.split(' / ').reverse().join(' / '), + ); + + expect(coord.ddm('LONLAT')).toBe(expected.ddm); + expect(coord.ddm('LATLON')).toBe( + expected.ddm.split(' / ').reverse().join(' / '), + ); + + expect(coord.dms('LONLAT')).toBe(expected.dms); + expect(coord.dms('LATLON')).toBe( + expected.dms.split(' / ').reverse().join(' / '), + ); + + expect(coord.mgrs()).toBe(expected.mgrs); + }, + ); + + const create = createCoordinate(coordinateSystems.dd, 'LATLON'); + + it('should NOT create a coordinate; invalid coordinate input', () => { + const coord = create(''); + + expect(coord.valid).toBe(false); + }); + + it('should NOT create a coordinate; conflict between bearings and format', () => { + const coord = create('1 E / 2 N'); + + expect(coord.valid).toBe(false); + expect(coord.errors).toEqual([ + '[ERROR] Mismatched formats: "LATLON" expected, "LONLAT" found.', + ]); + }); + + it('should NOT create a coordinate; invalid format, expecting Decimal Degrees', () => { + const coord = create('1 2 3 N / 5 6 7 W'); // too many numbers for DD + + expect(coord.valid).toBe(false); + expect(coord.errors).toEqual(['[ERROR] Invalid coordinate value.']); + }); +}); + +describe.each` + system | parser + ${'DD'} | ${parseDecimalDegrees} + ${'DDM'} | ${parseDegreesDecimalMinutes} + ${'DMS'} | ${parseDegreesMinutesSeconds} +`('exhastive error checks for %s', ({ parser, system }) => { + describe.each(['LATLON', 'LONLAT'] as [Format, Format])( + 'exhaustive errors for DD %s', + (format) => { + it.each(EXHAUSTIVE_ERRORS[system][format] as string[])('%s', (input) => { + expect(parser(format, input)[1].length !== 0); + }); + }, + ); +}); + +describe('raw coordinate parsing', () => { + describe('Decimal Degrees', () => { + it.each` + input | format | tokens | errors + ${'+27.5916 , -099.4523'} | ${'LATLON'} | ${'27.5916 N / 99.4523 W'} | ${[]} + ${'-45.123456 , 75.654321'} | ${'LATLON'} | ${'45.123456 S / 75.654321 E'} | ${[]} + ${'+45.0 , -75.0'} | ${'LATLON'} | ${'45 N / 75 W'} | ${[]} + ${'90.0 , 180.0'} | ${'LATLON'} | ${'90 N / 180 E'} | ${[]} + ${'-90.0 , +180.0'} | ${'LATLON'} | ${'90 S / 180 E'} | ${[]} + ${'+0.0 , -0.0'} | ${'LATLON'} | ${'0 N / 0 W'} | ${[]} + ${'+27.123456789 , -99.987654321'} | ${'LATLON'} | ${'27.123456789 N / 99.987654321 W'} | ${[]} + ${'+45.0 , -75.0'} | ${'LATLON'} | ${'45 N / 75 W'} | ${[]} + ${'180 , 90'} | ${'LONLAT'} | ${'180 E / 90 N'} | ${[]} + ${'180 , 91'} | ${'LONLAT'} | ${''} | ${['[ERROR] Degrees value (91) exceeds max value (90).']} + ${'181 , 90'} | ${'LONLAT'} | ${''} | ${['[ERROR] Degrees value (181) exceeds max value (180).']} + ${'-33 / 22 / 3'} | ${'LATLON'} | ${''} | ${['[ERROR] Invalid coordinate value.']} + ${'1 N / 2 E'} | ${'LONLAT'} | ${''} | ${['[ERROR] Mismatched formats: "LONLAT" expected, "LATLON" found.']} + `('$format, $input', ({ format, input, ...expected }) => { + const [tokens, errors] = parseDecimalDegrees(format, input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); + }); + + describe('Degrees Decimal Minutes', () => { + it.each` + input | format | tokens | errors + ${`40° 46.302' N, 79° 56.207' W`} | ${'LATLON'} | ${'40 46.302 N / 79 56.207 W'} | ${[]} + ${`90° 0' S, 180° 0' W`} | ${'LATLON'} | ${'90 0 S / 180 0 W'} | ${[]} + ${`0° 0' N 0° 0' E`} | ${'LATLON'} | ${'0 0 N / 0 0 E'} | ${[]} + ${`0° 0' N, 0° 0' E`} | ${'LATLON'} | ${'0 0 N / 0 0 E'} | ${[]} + ${`15° 45' S, 75° 45' E`} | ${'LATLON'} | ${'15 45 S / 75 45 E'} | ${[]} + ${`40°46.302'N,79°58'W`} | ${'LATLON'} | ${'40 46.302 N / 79 58 W'} | ${[]} + ${`40° 26' N, 79° 56.2' W`} | ${'LATLON'} | ${'40 26 N / 79 56.2 W'} | ${[]} + ${`40°26'N,79°58'W`} | ${'LATLON'} | ${'40 26 N / 79 58 W'} | ${[]} + ${`40° 26' W , 79° 58' N`} | ${'LONLAT'} | ${'40 26 W / 79 58 N'} | ${[]} + ${`12 ° 56 ' , 12° 56'`} | ${'LATLON'} | ${'12 56 N / 12 56 E'} | ${[]} + ${`12 ° 56 ' 12° 56'`} | ${'LATLON'} | ${'12 56 N / 12 56 E'} | ${[]} + ${'12 ° 56 12° 56'} | ${'LATLON'} | ${'12 56 N / 12 56 E'} | ${[]} + ${`89° 59.999" N, 179° 59.999"`} | ${'LATLON'} | ${''} | ${['[ERROR] Seconds indicator (") not valid in Degree Decimal Minutes.']} + ${`1° 2" 3°`} | ${'LONLAT'} | ${''} | ${['[ERROR] Seconds indicator (") not valid in Degree Decimal Minutes.']} + ${`12 ° 56 ' 12 56'`} | ${'LATLON'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${`12 ° 56 ' 12 56 `} | ${'LATLON'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${`9° 8' 9 8 `} | ${'LATLON'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${`9° -8' 9° 8 `} | ${'LATLON'} | ${''} | ${['[ERROR] Negative value for non-degrees value found.']} + `('$format, $input', ({ format, input, ...expected }) => { + const [tokens, errors] = parseDegreesDecimalMinutes(format, input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); + }); + + describe('Degrees Minutes Seconds', () => { + it.each` + input | format | tokens | errors + ${'1 N 1'} | ${'LATLON'} | ${'1 0 0 N / 1 0 0 E'} | ${[]} + ${'1 E 1'} | ${'LONLAT'} | ${'1 0 0 E / 1 0 0 N'} | ${[]} + ${`40° 26' 46.302" N, 79° 58' 56.207" W`} | ${'LATLON'} | ${'40 26 46.302 N / 79 58 56.207 W'} | ${[]} + ${`90° 0' 0" S, 180° 0' 0" W`} | ${'LATLON'} | ${'90 0 0 S / 180 0 0 W'} | ${[]} + ${`0° 0' 0" N 0° 0' 0" E`} | ${'LATLON'} | ${'0 0 0 N / 0 0 0 E'} | ${[]} + ${`0° 0' 0" N, 0° 0' 0" E`} | ${'LATLON'} | ${'0 0 0 N / 0 0 0 E'} | ${[]} + ${`15° 45' 30" S, 75° 45' 10" E`} | ${'LATLON'} | ${'15 45 30 S / 75 45 10 E'} | ${[]} + ${`89° 59' 59.999" N, 179° 59' 59.999"`} | ${'LATLON'} | ${'89 59 59.999 N / 179 59 59.999 E'} | ${[]} + ${` + 89 ° 59 59.999 " N, 179° 59 59.999" `} | ${'LATLON'} | ${'89 59 59.999 N / 179 59 59.999 E'} | ${[]} + ${`40°26'46.302"N,79°58'56.207"W`} | ${'LATLON'} | ${'40 26 46.302 N / 79 58 56.207 W'} | ${[]} + ${`40° 26' 46.3" N, 79° 58' 56.2" W`} | ${'LATLON'} | ${'40 26 46.3 N / 79 58 56.2 W'} | ${[]} + ${`40°26'46"N,79°58'56"W`} | ${'LATLON'} | ${'40 26 46 N / 79 58 56 W'} | ${[]} + ${`40° 26' 46.302" W , 79° 58' 56.207" N`} | ${'LONLAT'} | ${'40 26 46.302 W / 79 58 56.207 N'} | ${[]} + ${`12 ° 56 " , 12° 56"`} | ${'LATLON'} | ${'12 0 56 N / 12 0 56 E'} | ${[]} + ${`12 ° 56 " 12° 56"`} | ${'LATLON'} | ${'12 0 56 N / 12 0 56 E'} | ${[]} + ${'12 ° 56 12° 56'} | ${'LATLON'} | ${'12 56 0 N / 12 56 0 E'} | ${[]} + ${`1° 2" 3°`} | ${'LONLAT'} | ${'1 0 2 E / 3 0 0 N'} | ${[]} + ${`40° 26' 46.302" N 79° 58' 56.207" W`} | ${'LATLON'} | ${'40 26 46.302 N / 79 58 56.207 W'} | ${[]} + ${`12 ° 56 " 12 56"`} | ${'LATLON'} | ${'12 0 56 N / 12 0 56 E'} | ${[]} + ${`12 ° 56 " 12 56 `} | ${'LATLON'} | ${'12 0 56 N / 12 56 0 E'} | ${[]} + ${`9° 8" 9 8 `} | ${'LATLON'} | ${'9 0 8 N / 9 8 0 E'} | ${[]} + ${`9° 8' 9 8 `} | ${'LATLON'} | ${''} | ${['[ERROR] Ambiguous grouping of numbers with no divider.']} + ${`91° 0' 0" N, 79° 58' 56" W`} | ${'LATLON'} | ${''} | ${['[ERROR] Degrees value (91°) exceeds max value (90).']} + ${`45° 60' 0" N, 75° 45' 10" W`} | ${'LATLON'} | ${''} | ${["[ERROR] Minutes value (60') exceeds max value (59)."]} + ${`45° 30' 61" N, 75° 45' 0" W`} | ${'LATLON'} | ${''} | ${['[ERROR] Seconds value (61") exceeds max value (59.999999999).']} + ${`45°0'0"N, 181° 0' 0" W`} | ${'LATLON'} | ${''} | ${['[ERROR] Degrees value (181°) exceeds max value (180).']} + ${`40.1° 26' 46" N, 79° 58' 56" W`} | ${'LATLON'} | ${''} | ${['[ERROR] Degrees value (40.1°) must not include decimal value.']} + ${`+40° 26' 46.302" N, -79° 58' 56.207" E`} | ${'LATLON'} | ${''} | ${['[ERROR] Bearing (E) conflicts with negative number (-79°).']} + ${`45° -10' 10" N, 75° 30' 10" W`} | ${'LATLON'} | ${''} | ${['[ERROR] Negative value for non-degrees value found.']} + ${`45° -10' 10" N, 75° 30' 10" W`} | ${'LONLAT'} | ${''} | ${['[ERROR] Negative value for non-degrees value found.']} + ${`45° -10' 10" N, 75° 30' 10" W extra stuff`} | ${'LATLON'} | ${''} | ${['[ERROR] Too many bearings.']} + ${`45° -10' 10" N, 75° 30' 10" W 213`} | ${'LATLON'} | ${''} | ${['[ERROR] Too many numbers.']} + ${`45° 10' 10" E, 75° 30' 10" N`} | ${'LATLON'} | ${''} | ${['[ERROR] Mismatched formats: "LATLON" expected, "LONLAT" found.']} + `('$format, $input', ({ format, input, ...expected }) => { + const [tokens, errors] = parseDegreesMinutesSeconds(format, input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); + }); + + describe('Military Grid Reference System', () => { + it.each` + input | format | tokens | errors + ${'30U WB 85358 69660'} | ${'LATLON'} | ${'51.1719929 N / 1.779008 W'} | ${[]} + `('$input -> $tokens', ({ format, input, ...expected }) => { + const [tokens, errors] = parseMGRS(format, input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); + + it.each` + input | error + ${''} | ${'No input provided'} + ${'-1'} | ${'Invalid UTM zone number (-1) found in grid zone designation'} + ${'99'} | ${'Invalid UTM zone number (99) found in grid zone designation'} + ${'30I'} | ${'Invalid Latitude band letter (I) found in grid zone designation'} + ${'30O'} | ${'Invalid Latitude band letter (O) found in grid zone designation'} + ${'30K OI'} | ${'Invalid 100K m square identification (OI) found'} + ${'30X 0 0'} | ${'Invalid numerical location (0,0) found'} + ${'30X HH 0'} | ${'Invalid numerical location (0,) found'} + `('$input -> $error', ({ input, ...expected }) => { + const [tokens, errors] = parseMGRS('LATLON', input); + + expect(errors).toStrictEqual( + expected.error + ? [`[ERROR] ${expected.error}; expected format DDZ AA DDD DDD.`] + : [], + ); + expect(tokens).toStrictEqual([]); + }); + }); + + describe('Universal Transverse Mercator', () => { + it.each` + input | format | tokens | errors + ${'30 N 585358 5669660'} | ${'LATLON'} | ${'51.1719929 N / 1.779008 W'} | ${[]} + ${'18 N 585628 4511322'} | ${'LATLON'} | ${'40.7483961 N / 73.9857049 W'} | ${[]} + `('$input -> $tokens', ({ format, input, ...expected }) => { + const [tokens, errors] = parseUTM(format, input); + + expect(errors).toStrictEqual(expected.errors); + expect(tokens).toStrictEqual(expected.tokens.split(' ').filter(Boolean)); + }); + + it.each` + input | error + ${''} | ${'Invalid Zone number () found'} + ${'-1'} | ${'Invalid Zone number (-1) found'} + ${'99'} | ${'Invalid Zone number (99) found'} + ${'30U'} | ${'Invalid Latitude band letter (U) found'} + ${'30 N'} | ${'Invalid Easting number () found'} + ${'30 N 0'} | ${'Invalid Northing number () found'} + `('$input -> $error', ({ input, ...expected }) => { + const [tokens, errors] = parseUTM('LATLON', input); + + expect(errors).toStrictEqual( + expected.error + ? [`[ERROR] ${expected.error}; expected format ZZ N|S DDD DDD.`] + : [], + ); + expect(tokens).toStrictEqual([]); + }); + }); +}); diff --git a/packages/geo/src/index.ts b/packages/geo/src/index.ts index de4f2bcb..d5330fb7 100644 --- a/packages/geo/src/index.ts +++ b/packages/geo/src/index.ts @@ -2,5 +2,9 @@ * THIS IS A GENERATED FILE. DO NOT ALTER DIRECTLY. */ -export { matchDD, matchDMS } from './coordinates/match'; -export { normalizeDecimalDegree } from './coordinates/normalize'; +export { coordinateSystems, createCoordinate } from './coordinates/coordinate'; +export { parseDecimalDegrees } from './coordinates/latlon/decimal-degrees/parser'; +export { parseDegreesDecimalMinutes } from './coordinates/latlon/degrees-decimal-minutes/parser'; +export { parseDegreesMinutesSeconds } from './coordinates/latlon/degrees-minutes-seconds/parser'; +export { parseMGRS } from './coordinates/mgrs/parser'; +export { parseUTM } from './coordinates/utm/parser'; diff --git a/packages/geo/src/patterning.test.ts b/packages/geo/src/patterning.test.ts new file mode 100644 index 00000000..0074d341 --- /dev/null +++ b/packages/geo/src/patterning.test.ts @@ -0,0 +1,48 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, it, describe } from 'vitest'; + +import * as Patterning from './patterning'; + +describe('Patterning', () => { + it.each` + method | expected + ${Patterning.capture} | ${['(abc)', '(abc)']} + ${Patterning.group} | ${['(?:abc)', '(?:abc)']} + ${Patterning.merge} | ${['abc', 'abc']} + ${Patterning.optional} | ${['(?:abc)?', '(?:abc)?']} + `('$method.name', ({ expected, method }) => { + expect(method(/a/, /b/, /c/).source).toBe(expected[0]); + expect(method(/abc/).source).toBe(expected[1]); + }); + + describe('fromTemplate', () => { + it('should build a regex from parts', () => { + const parts = { + alpha: /alpha/, + beta: /beta/, + + ' ': / /, + }; + + expect(Patterning.fromTemplate(parts, 'beta alpha').toString()).toBe( + /^beta alpha$/.toString(), + ); + + expect( + Patterning.fromTemplate(parts, 'alpha beta missing').toString(), + ).toBe(/^alpha beta _MISS_missing_MISS_$/.toString()); + }); + }); +}); diff --git a/packages/geo/src/patterning.ts b/packages/geo/src/patterning.ts new file mode 100644 index 00000000..1c9e49d1 --- /dev/null +++ b/packages/geo/src/patterning.ts @@ -0,0 +1,84 @@ +// __private-exports +/* + * Copyright 2024 Hypergiant Galactic Systems Inc. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Create a "capturing" group with the pattern provided by all arguments + * merged into a single regex. + * + * @pure + * + * @example + * capture(/a/, /b/, /c/) === /(abc)/ + */ +export const capture = (...p: RegExp[]) => + new RegExp(`(${merge(...p).source})`); + +/** + * Create a pattern using a template string and dict(ionary) of terms. + * + * @param dict an object with keys/properties that are RegExp objects that will + * be used in the template string + * @param template the definition of the pattern to build using the dict(ionary) + * patterns + * + * @pure + * + * @example + * fromTemplate({ a: /alpha/, b: /beta/, ' ': / / }, 'a b') === /^alpha beta$/ + * + */ +export const fromTemplate = (dict: Record, template: string) => + merge( + /^/, + ...(template.match(/(\s+)|([^\s]+)/g) ?? []).map( + (t) => dict[t as keyof typeof dict] ?? new RegExp(`_MISS_${t}_MISS_`), + ), + /$/, + ); + +/** + * Create a "non-capturing" group with the pattern provided by all arguments + * merged into a single regex. + * + * @pure + * + * @example + * capture(/a/, /b/, /c/) === /(?:abc)/ + */ +export const group = (...p: RegExp[]) => + new RegExp(`(?:${merge(...p).source})`); + +/** + * Concatenate all of the provided patterns into a single pattern; each + * subsequent argument is joined with the previous with no "qualifier" between + * them. + * + * @pure + * + * @example + * merge(/a/, /b/, /c/) === /abc/ + */ +export const merge = (...all: RegExp[]) => + all.reduce((acc, next) => new RegExp(acc?.source + next.source)); + +/** + * Create an "optional" "non-capturing" group with the pattern provided by all + * arguments merged into a single regex. + * + * @pure + * + * @example + * capture(/a/, /b/, /c/) === /(?:abc)?/ + */ +export const optional = (...p: RegExp[]) => + new RegExp(`(?:${merge(...p).source})?`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f423e65..d156edbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 2.1.1 next: specifier: 15.0.3 - version: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -107,7 +107,7 @@ importers: version: 18.3.1 '@vanilla-extract/next-plugin': specifier: ^2.4.6 - version: 2.4.6(@types/node@20.17.6)(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.36.0)(webpack@5.96.1(@swc/core@1.9.2(@swc/helpers@0.5.15))(esbuild@0.23.1)) + version: 2.4.6(@types/node@20.17.6)(next@15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.36.0)(webpack@5.96.1(@swc/core@1.9.2(@swc/helpers@0.5.15))(esbuild@0.23.1)) '@vanilla-extract/vite-plugin': specifier: ^4.0.16 version: 4.0.17(@types/node@20.17.6)(terser@5.36.0)(vite@5.4.11(@types/node@20.17.6)(terser@5.36.0)) @@ -150,13 +150,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -187,13 +187,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -218,13 +218,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -372,13 +372,13 @@ importers: version: 18.3.1(react@18.3.1) tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vite: specifier: ^5.4.9 version: 5.4.11(@types/node@20.17.6)(terser@5.36.0) @@ -406,13 +406,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -425,6 +425,12 @@ importers: '@accelint/predicates': specifier: workspace:0.1.3 version: link:../predicates + '@ngageoint/grid-js': + specifier: ^2.1.0 + version: 2.1.0 + '@ngageoint/mgrs-js': + specifier: ^1.0.0 + version: 1.0.0 typescript: specifier: ^5.6.3 version: 5.6.3 @@ -443,13 +449,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -474,13 +480,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -511,13 +517,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -542,13 +548,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -576,13 +582,13 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) typedoc: specifier: ^0.26.11 version: 0.26.11(typescript@5.6.3) typedoc-plugin-markdown: specifier: ^4.2.10 - version: 4.2.10(typedoc@0.26.11(typescript@5.6.3)) + version: 4.3.1(typedoc@0.26.11(typescript@5.6.3)) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -607,7 +613,7 @@ importers: version: 2.1.3 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0) + version: 8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1) vitest: specifier: ^2.1.3 version: 2.1.5(@types/node@20.17.6)(jsdom@25.0.1)(msw@2.6.4(@types/node@20.17.6)(typescript@5.6.3))(terser@5.36.0) @@ -1497,7 +1503,6 @@ packages: '@ls-lint/ls-lint@2.3.0-beta.1': resolution: {integrity: sha512-NmujBNFslP4GhFsB92zh1evSSBadcg+RMsQ5FM70OrU36C3AbQYaVXDn4reC28GM6DFoOtiETRdKV6ie1TfP7g==} - cpu: [x64, arm64, s390x, ppc64le] os: [darwin, linux, win32] hasBin: true @@ -1571,6 +1576,21 @@ packages: cpu: [x64] os: [win32] + '@ngageoint/color-js@2.1.0': + resolution: {integrity: sha512-jiiZRtEgwIpMUEUVcef7kWGLd7yOSvHKHzxbGOV9YAXulYTnl9LPiq7uY6wO95iJ8NSVoanWTnRrDJ49NF+g5A==} + engines: {npm: '>= 8.x'} + + '@ngageoint/grid-js@2.1.0': + resolution: {integrity: sha512-PKgkFPgRLU+qXCkfCU/jSZzyI0FlIOjKwTQTEDYR4pXGWXtSmYdor7xUQ/Cq5K/J/4UFJfd55BG7YGguAy9VhA==} + engines: {npm: '>= 8.x'} + + '@ngageoint/mgrs-js@1.0.0': + resolution: {integrity: sha512-+5bOU2+LWEIK6SZTUMNwbTUhH6DdwvF5+HOzC70mHktwhdNm413ZyncrHzcPov160ItZzd34OazubO1LX49y0w==} + engines: {npm: '>= 6.x'} + + '@ngageoint/simple-features-js@1.1.0': + resolution: {integrity: sha512-QhJvGiEvPiR5Mk+dUQNsxJF+A77mFfdiOU1CU95q0oHdaEuixrJrrPwJZeG2pSICRNlc36P65Hp26zjzgt7v5Q==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2237,17 +2257,17 @@ packages: cpu: [x64] os: [win32] - '@shikijs/core@1.23.1': - resolution: {integrity: sha512-NuOVgwcHgVC6jBVH5V7iblziw6iQbWWHrj5IlZI3Fqu2yx9awH7OIQkXIcsHsUmY19ckwSgUMgrqExEyP5A0TA==} + '@shikijs/core@1.24.0': + resolution: {integrity: sha512-6pvdH0KoahMzr6689yh0QJ3rCgF4j1XsXRHNEeEN6M4xJTfQ6QPWrmHzIddotg+xPJUPEPzYzYCKzpYyhTI6Gw==} - '@shikijs/engine-javascript@1.23.1': - resolution: {integrity: sha512-i/LdEwT5k3FVu07SiApRFwRcSJs5QM9+tod5vYCPig1Ywi8GR30zcujbxGQFJHwYD7A5BUqagi8o5KS+LEVgBg==} + '@shikijs/engine-javascript@1.24.0': + resolution: {integrity: sha512-ZA6sCeSsF3Mnlxxr+4wGEJ9Tto4RHmfIS7ox8KIAbH0MTVUkw3roHPHZN+LlJMOHJJOVupe6tvuAzRpN8qK1vA==} - '@shikijs/engine-oniguruma@1.23.1': - resolution: {integrity: sha512-KQ+lgeJJ5m2ISbUZudLR1qHeH3MnSs2mjFg7bnencgs5jDVPeJ2NVDJ3N5ZHbcTsOIh0qIueyAJnwg7lg7kwXQ==} + '@shikijs/engine-oniguruma@1.24.0': + resolution: {integrity: sha512-Eua0qNOL73Y82lGA4GF5P+G2+VXX9XnuUxkiUuwcxQPH4wom+tE39kZpBFXfUuwNYxHSkrSxpB1p4kyRW0moSg==} - '@shikijs/types@1.23.1': - resolution: {integrity: sha512-98A5hGyEhzzAgQh2dAeHKrWW4HfCMeoFER2z16p5eJ+vmPeF6lZ/elEne6/UCU551F/WqkopqRsr1l2Yu6+A0g==} + '@shikijs/types@1.24.0': + resolution: {integrity: sha512-aptbEuq1Pk88DMlCe+FzXNnBZ17LCiLIGWAeCWhoFDzia5Q5Krx3DgnULLiouSdd6+LUM39XwXGppqYE0Ghtug==} '@shikijs/vscode-textmate@9.3.0': resolution: {integrity: sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==} @@ -2983,6 +3003,9 @@ packages: supports-color: optional: true + decimal-format@3.0.1: + resolution: {integrity: sha512-L07urvPTg4RoTTAMsbZlVPdGoQKeQRLukLovp/6aTufiwGnVHY4Q6L4hELAFAWnh6zT0dZpZGXU/oSrli+SG9w==} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -3625,6 +3648,9 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + js_cols@1.0.1: + resolution: {integrity: sha512-VJuDOf7txQznLQSlJ7p90Cawz5A1+T4MsOYF01V1WqK7TJOnHvvC8Oz8qtc7KDfDyfP05jP5ua7VOpUm/S6s+g==} + jsdom@25.0.1: resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} engines: {node: '>=18'} @@ -4078,8 +4104,8 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - oniguruma-to-es@0.4.1: - resolution: {integrity: sha512-rNcEohFz095QKGRovP/yqPIKc+nP+Sjs4YTHMv33nMePGKrq/r2eu9Yh4646M5XluGJsUnmwoXuiXE69KDs+fQ==} + oniguruma-to-es@0.7.0: + resolution: {integrity: sha512-HRaRh09cE0gRS3+wi2zxekB+I5L8C/gN60S+vb11eADHUaB/q4u8wGGOX3GvwvitG8ixaeycZfeoyruKQzUgNg==} only@0.0.2: resolution: {integrity: sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==} @@ -4413,8 +4439,8 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - regex-recursion@4.2.1: - resolution: {integrity: sha512-QHNZyZAeKdndD1G3bKAbBEKOSSK4KOHQrAJ01N1LJeb0SoH4DJIeFhp0uUpETgONifS4+P3sOgoA1dhzgrQvhA==} + regex-recursion@4.3.0: + resolution: {integrity: sha512-5LcLnizwjcQ2ALfOj95MjcatxyqF5RPySx9yT+PaXu3Gox2vyAtLDjHB8NTJLtMGkvyau6nI3CfpwFCjPUIs/A==} regex-utilities@2.3.0: resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} @@ -4549,8 +4575,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shiki@1.23.1: - resolution: {integrity: sha512-8kxV9TH4pXgdKGxNOkrSMydn1Xf6It8lsle0fiqxf7a1149K1WGtdOu3Zb91T5r1JpvRPxqxU3C2XdZZXQnrig==} + shiki@1.24.0: + resolution: {integrity: sha512-qIneep7QRwxRd5oiHb8jaRzH15V/S8F3saCXOdjwRLgozZJr5x2yeBhQtqkO3FSzQDwYEFAYuifg4oHjpDghrg==} siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -4608,6 +4634,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4756,6 +4785,9 @@ packages: resolution: {integrity: sha512-Kw36UHxJEELq2VUqdaSGR2/8cAsPgMtvX8uGVU6Jk26O66PhXec0A5ZnRYs47btbtwPDpXXF66+Fo3vimCM9aQ==} engines: {node: '>=16'} + timsort@0.3.0: + resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4845,6 +4877,9 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tstl@2.5.16: + resolution: {integrity: sha512-+O2ybLVLKcBwKm4HymCEwZIT0PpwS3gCYnxfSDEjJEKADvIFruaQjd3m7CAKNU1c7N3X3WjVz87re7TA2A5FUw==} + tsup@8.3.5: resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} @@ -4919,11 +4954,11 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - typedoc-plugin-markdown@4.2.10: - resolution: {integrity: sha512-PLX3pc1/7z13UJm4TDE9vo9jWGcClFUErXXtd5LdnoLjV6mynPpqZLU992DwMGFSRqJFZeKbVyqlNNeNHnk2tQ==} + typedoc-plugin-markdown@4.3.1: + resolution: {integrity: sha512-cV0cjvNfr5keytkWUm5AXNFcW3/dd51BYFvbAVqo9AJbHZjt5SGkf2EZ0whSKCilqpwL7biPC/r1WNeW2NbV/w==} engines: {node: '>= 18'} peerDependencies: - typedoc: 0.26.x + typedoc: 0.27.x typedoc@0.26.11: resolution: {integrity: sha512-sFEgRRtrcDl2FxVP58Ze++ZK2UQAEvtvvH8rRlig1Ja3o7dDaMHmaBfvJmdGnNEFaLTpQsN8dpvZaTqJSu/Ugw==} @@ -5204,8 +5239,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@2.6.0: - resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} + yaml@2.6.1: + resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==} engines: {node: '>= 14'} hasBin: true @@ -6119,6 +6154,29 @@ snapshots: '@next/swc-win32-x64-msvc@15.0.3': optional: true + '@ngageoint/color-js@2.1.0': {} + + '@ngageoint/grid-js@2.1.0': + dependencies: + '@ngageoint/color-js': 2.1.0 + '@ngageoint/simple-features-js': 1.1.0 + '@types/lodash': 4.17.13 + lodash: 4.17.21 + tstl: 2.5.16 + + '@ngageoint/mgrs-js@1.0.0': + dependencies: + '@ngageoint/color-js': 2.1.0 + '@ngageoint/grid-js': 2.1.0 + decimal-format: 3.0.1 + sprintf-js: 1.1.3 + tstl: 2.5.16 + + '@ngageoint/simple-features-js@1.1.0': + dependencies: + js_cols: 1.0.1 + timsort: 0.3.0 + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7186,27 +7244,27 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.26.0': optional: true - '@shikijs/core@1.23.1': + '@shikijs/core@1.24.0': dependencies: - '@shikijs/engine-javascript': 1.23.1 - '@shikijs/engine-oniguruma': 1.23.1 - '@shikijs/types': 1.23.1 + '@shikijs/engine-javascript': 1.24.0 + '@shikijs/engine-oniguruma': 1.24.0 + '@shikijs/types': 1.24.0 '@shikijs/vscode-textmate': 9.3.0 '@types/hast': 3.0.4 hast-util-to-html: 9.0.3 - '@shikijs/engine-javascript@1.23.1': + '@shikijs/engine-javascript@1.24.0': dependencies: - '@shikijs/types': 1.23.1 + '@shikijs/types': 1.24.0 '@shikijs/vscode-textmate': 9.3.0 - oniguruma-to-es: 0.4.1 + oniguruma-to-es: 0.7.0 - '@shikijs/engine-oniguruma@1.23.1': + '@shikijs/engine-oniguruma@1.24.0': dependencies: - '@shikijs/types': 1.23.1 + '@shikijs/types': 1.24.0 '@shikijs/vscode-textmate': 9.3.0 - '@shikijs/types@1.23.1': + '@shikijs/types@1.24.0': dependencies: '@shikijs/vscode-textmate': 9.3.0 '@types/hast': 3.0.4 @@ -7488,10 +7546,10 @@ snapshots: - supports-color - terser - '@vanilla-extract/next-plugin@2.4.6(@types/node@20.17.6)(next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.36.0)(webpack@5.96.1(@swc/core@1.9.2(@swc/helpers@0.5.15))(esbuild@0.23.1))': + '@vanilla-extract/next-plugin@2.4.6(@types/node@20.17.6)(next@15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.36.0)(webpack@5.96.1(@swc/core@1.9.2(@swc/helpers@0.5.15))(esbuild@0.23.1))': dependencies: '@vanilla-extract/webpack-plugin': 2.3.14(@types/node@20.17.6)(terser@5.36.0)(webpack@5.96.1(@swc/core@1.9.2(@swc/helpers@0.5.15))(esbuild@0.23.1)) - next: 15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -8045,6 +8103,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal-format@3.0.1: {} + decimal.js@10.4.3: {} decode-named-character-reference@1.0.2: @@ -8802,6 +8862,8 @@ snapshots: dependencies: argparse: 2.0.1 + js_cols@1.0.1: {} + jsdom@25.0.1: dependencies: cssstyle: 4.1.0 @@ -9492,7 +9554,7 @@ snapshots: neo-async@2.6.2: {} - next@15.0.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.0.3 '@swc/counter': 0.1.3 @@ -9502,7 +9564,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@18.3.1) optionalDependencies: '@next/swc-darwin-arm64': 15.0.3 '@next/swc-darwin-x64': 15.0.3 @@ -9556,11 +9618,11 @@ snapshots: dependencies: mimic-fn: 2.1.0 - oniguruma-to-es@0.4.1: + oniguruma-to-es@0.7.0: dependencies: emoji-regex-xs: 1.0.0 regex: 5.0.2 - regex-recursion: 4.2.1 + regex-recursion: 4.3.0 only@0.0.2: {} @@ -9680,14 +9742,14 @@ snapshots: mlly: 1.7.3 pathe: 1.1.2 - postcss-load-config@6.0.1(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.0): + postcss-load-config@6.0.1(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.1): dependencies: lilconfig: 3.1.2 optionalDependencies: jiti: 2.4.0 postcss: 8.4.49 tsx: 4.19.2 - yaml: 2.6.0 + yaml: 2.6.1 postcss-value-parser@4.2.0: {} @@ -9987,7 +10049,7 @@ snapshots: regenerator-runtime@0.14.1: {} - regex-recursion@4.2.1: + regex-recursion@4.3.0: dependencies: regex-utilities: 2.3.0 @@ -10183,12 +10245,12 @@ snapshots: shebang-regex@3.0.0: {} - shiki@1.23.1: + shiki@1.24.0: dependencies: - '@shikijs/core': 1.23.1 - '@shikijs/engine-javascript': 1.23.1 - '@shikijs/engine-oniguruma': 1.23.1 - '@shikijs/types': 1.23.1 + '@shikijs/core': 1.24.0 + '@shikijs/engine-javascript': 1.24.0 + '@shikijs/engine-oniguruma': 1.24.0 + '@shikijs/types': 1.24.0 '@shikijs/vscode-textmate': 9.3.0 '@types/hast': 3.0.4 @@ -10235,6 +10297,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + stackback@0.0.2: {} statuses@1.5.0: {} @@ -10296,10 +10360,12 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(react@18.3.1): + styled-jsx@5.1.6(@babel/core@7.26.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 + optionalDependencies: + '@babel/core': 7.26.0 sucrase@3.35.0: dependencies: @@ -10395,6 +10461,8 @@ snapshots: tightrope@0.2.0: {} + timsort@0.3.0: {} + tinybench@2.9.0: {} tinyexec@0.3.1: {} @@ -10463,7 +10531,9 @@ snapshots: tsscmp@1.0.6: {} - tsup@8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0): + tstl@2.5.16: {} + + tsup@8.3.5(@swc/core@1.9.2(@swc/helpers@0.5.15))(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.1): dependencies: bundle-require: 5.0.0(esbuild@0.24.0) cac: 6.7.14 @@ -10473,7 +10543,7 @@ snapshots: esbuild: 0.24.0 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.0) + postcss-load-config: 6.0.1(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(yaml@2.6.1) resolve-from: 5.0.0 rollup: 4.26.0 source-map: 0.8.0-beta.0 @@ -10536,7 +10606,7 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - typedoc-plugin-markdown@4.2.10(typedoc@0.26.11(typescript@5.6.3)): + typedoc-plugin-markdown@4.3.1(typedoc@0.26.11(typescript@5.6.3)): dependencies: typedoc: 0.26.11(typescript@5.6.3) @@ -10545,9 +10615,9 @@ snapshots: lunr: 2.3.9 markdown-it: 14.1.0 minimatch: 9.0.5 - shiki: 1.23.1 + shiki: 1.24.0 typescript: 5.6.3 - yaml: 2.6.0 + yaml: 2.6.1 typescript@5.6.3: {} @@ -10861,7 +10931,7 @@ snapshots: yallist@3.1.1: {} - yaml@2.6.0: {} + yaml@2.6.1: {} yargs-parser@21.1.1: {} diff --git a/scripts/indexer.mjs b/scripts/indexer.mjs index b6540284..3285250f 100644 --- a/scripts/indexer.mjs +++ b/scripts/indexer.mjs @@ -103,7 +103,8 @@ async function codeFileExports(filePath) { const codeNames = codeExports .flatMap((node) => node.declaration?.type === 'TSEnumDeclaration' || - node.declaration?.type === 'FunctionDeclaration' + node.declaration?.type === 'FunctionDeclaration' || + node.declaration?.type === 'ClassDeclaration' ? [node.declaration?.id.name] : node.declaration?.declarations?.map((dec) => dec.id.name), )