From d4db56a3b74e1aa57e8190967750f3288e28a34f Mon Sep 17 00:00:00 2001 From: Stephan Besser Date: Wed, 4 Dec 2024 12:36:26 +0100 Subject: [PATCH] feat: implement DmsSignedFormat for parsing DMS signed coordinates and add related tests --- src/formats/base-format.ts | 64 +++++++++++++++++++++++--- src/formats/dms-signed-format.ts | 78 ++++++++++++++++++++++++++++++++ tests/dms-signed-format.test.ts | 61 +++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 src/formats/dms-signed-format.ts create mode 100644 tests/dms-signed-format.test.ts diff --git a/src/formats/base-format.ts b/src/formats/base-format.ts index 4586081..9b4db62 100644 --- a/src/formats/base-format.ts +++ b/src/formats/base-format.ts @@ -44,7 +44,7 @@ export class BaseFormat implements IFormatParser { * number and within the range of -180 to 180. If the value is not valid, the method throws an * error. */ - enforceValidLongitude(lonValue) { + enforceValidLongitude(lonValue: any): void { if (isNumeric(lonValue) === false) { throw new Error('longitude must be numeric'); } @@ -54,11 +54,63 @@ export class BaseFormat implements IFormatParser { } } + enforceValidLatitudeDegrees(degrees: any, signed = true): void { + if (isNumeric(degrees) === false) { + throw new Error('degrees must be numeric'); + } + const deg = Number.parseFloat(degrees.toString()); + if (signed) { + if (deg < -90 || deg > 90) { + throw new Error('latitude degrees must be within the range of -90 to 90'); + } + } else { + if (deg < 0 || deg > 90) { + throw new Error('latitude degrees must be within the range of 0 to 90'); + } + } + } + + enforceValidLongitudeDegrees(degrees: any, signed = true): void { + if (isNumeric(degrees) === false) { + throw new Error('degrees must be numeric'); + } + const deg = Number.parseFloat(degrees.toString()); + if (signed) { + if (deg < -180 || deg > 180) { + throw new Error('longitude degrees must be within the range of -180 to 180'); + } + } else { + if (deg < 0 || deg > 180) { + throw new Error('longitude degrees must be within the range of 0 to 180'); + } + } + } + + enforceValidMinutes(minutes: any): void { + if (isNumeric(minutes) === false) { + throw new Error('minutes must be numeric'); + } + const min = Number.parseFloat((minutes as any).toString()); + if (min < 0 || min >= 60) { + throw new Error('minutes must be within the range of 0 to 59'); + } + } + + enforceValidSeconds(seconds: any): void { + if (isNumeric(seconds) === false) { + throw new Error('seconds must be numeric'); + } + const sec = Number.parseFloat(seconds.toString()); + if (sec < 0 || sec >= 60) { + throw new Error('seconds must be within the range of 0 to 59'); + } + } + /** * Enforces that a given input string is a valid latitude value. This means that the value is a * number and within the range of -90 to 90. If the value is not valid, the method throws an */ - enforceValidLatitude(latValue) { + enforceValidLatitude(latValue: any): void { if (isNumeric(latValue) === false) { throw new Error('latitude must be numeric'); } @@ -72,7 +124,7 @@ export class BaseFormat implements IFormatParser { * Enforces that a given input string does not contain a hyphen. If the value contains a hyphen, * the method throws an error. */ - enforceNoHyphen(coordinateString: string): void { + enforceNoHyphen(coordinateString: any): void { validateSchema(coordinateString, z.string(), { assert: true, name: 'coordinateString' }); if (coordinateString.includes('-')) { @@ -90,9 +142,9 @@ export class BaseFormat implements IFormatParser { // Calculate the decimal value let decimal = - parseInt(degrees.toString()) + - parseFloat(minutes.toString()) / 60 + - (seconds ? parseFloat(seconds.toString()) / 3600 : 0); + Number.parseInt(degrees.toString()) + + Number.parseFloat(minutes.toString()) / 60 + + Number.parseFloat(seconds.toString()) / 3600; // Adjust for direction (North/East = positive, South/West = negative) if (direction === 'S' || direction === 'W') { diff --git a/src/formats/dms-signed-format.ts b/src/formats/dms-signed-format.ts new file mode 100644 index 0000000..c74dd01 --- /dev/null +++ b/src/formats/dms-signed-format.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; +import type { Coordinate } from '../types.js'; +import { validateSchema } from '../validate-schema.js'; +import { BaseFormat } from './base-format.js'; + +const REGEX = /^(-?\d+)°\s*(\d+)'\s*(\d+(?:\.\d+)?)\",?\s*(-?\d+)°\s*(\d+)'\s*(\d+(?:\.\d+)?)\"$/; + +/** + * Parses coordinates strings in DMS signed format. Coordinate ordering is + * always latitude, longitude. + * + * Supported formats: + * + * 40°7'23" -74°7'23" + * 40°7'23", -74°7'23" + * 40°7'23.123", -74°7'23.123" + */ +export class DmsSignedFormat extends BaseFormat { + parse(coordinateString: string): Coordinate { + validateSchema(coordinateString, z.string(), { assert: true, name: 'coordinateString' }); + + if (DmsSignedFormat.canParse(coordinateString) === false) { + throw new Error('Invalid coordinate string'); + } + // use the regex to parse the latitude and longitude + const match = coordinateString.match(REGEX); + if (match == null) { + throw new Error('Invalid coordinate string'); + } + const matchLatDegree = match[1]; + const matchLatMinutes = match[2]; + const matchLatSeconds = match[3]; + const matchLonDegree = match[4]; + const matchLonMinutes = match[5]; + const matchLonSeconds = match[6]; + + this.enforceValidLatitudeDegrees(matchLatDegree); + this.enforceValidMinutes(matchLatMinutes); + this.enforceValidSeconds(matchLatSeconds); + this.enforceValidLongitudeDegrees(matchLonDegree); + this.enforceValidMinutes(matchLonMinutes); + this.enforceValidSeconds(matchLonSeconds); + + const latDegree = Number.parseInt(matchLatDegree); + const latMinutes = Number.parseInt(matchLatMinutes); + const latSeconds = Number.parseFloat(matchLatSeconds); + const lonDegree = Number.parseInt(matchLonDegree); + const lonMinutes = Number.parseInt(matchLonMinutes); + const lonSeconds = Number.parseFloat(matchLonSeconds); + + const decimalLat = this.dmsToDecimal({ + degrees: Math.abs(latDegree), + minutes: latMinutes, + seconds: latSeconds, + direction: latDegree < 0 ? 'S' : 'N', + }); + const decimalLon = this.dmsToDecimal({ + degrees: Math.abs(lonDegree), + minutes: lonMinutes, + seconds: lonSeconds, + direction: lonDegree < 0 ? 'W' : 'E', + }); + + const lat = decimalLat.toFixed(this.precision); + const lon = decimalLon.toFixed(this.precision); + + return { + latitude: parseFloat(lat), + longitude: parseFloat(lon), + }; + } + + static canParse(coordinateString: string): boolean { + validateSchema(coordinateString, z.string(), { assert: true, name: 'coordinateString' }); + + return REGEX.test(coordinateString); + } +} diff --git a/tests/dms-signed-format.test.ts b/tests/dms-signed-format.test.ts new file mode 100644 index 0000000..24754fd --- /dev/null +++ b/tests/dms-signed-format.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { DmsSignedFormat } from '../src/formats/dms-signed-format.js'; + +describe('canParse', () => { + it('returns true for known formats', () => { + expect(DmsSignedFormat.canParse(`40°7'23" -74°7'23"`)).toBe(true); + expect(DmsSignedFormat.canParse(`40°7'23", -74°7'23"`)).toBe(true); + expect(DmsSignedFormat.canParse(`40°7'23",-74°7'23"`)).toBe(true); + expect(DmsSignedFormat.canParse(`40° 7' 23" -74° 7' 23"`)).toBe(true); + expect(DmsSignedFormat.canParse(`40° 7' 23", -74° 7' 23"`)).toBe(true); + expect(DmsSignedFormat.canParse(`40° 7' 23.9999", -74° 7' 23.9999"`)).toBe(true); + }); + + it('returns false for unknown formats', () => { + expect(DmsSignedFormat.canParse('1.234 5.678')).toBe(false); + }); +}); +describe('parse', () => { + it(`returns the correct latitude and longitude for 40°7'23" -74°7'23"`, () => { + const formatParser = new DmsSignedFormat(); + const result = formatParser.parse(`40°7'23" -74°7'23"`); + expect(result.latitude).toBe(40.123); + expect(result.longitude).toBe(-74.123); + }); + + it(`returns the correct latitude and longitude for 40°7'23", -74°7'23"`, () => { + const formatParser = new DmsSignedFormat(); + const result = formatParser.parse(`40°7'23", -74°7'23"`); + expect(result.latitude).toBe(40.123); + expect(result.longitude).toBe(-74.123); + }); + + it(`returns the correct latitude and longitude for 40°7'23",-74°7'23"`, () => { + const formatParser = new DmsSignedFormat(); + const result = formatParser.parse(`40°7'23",-74°7'23"`); + expect(result.latitude).toBe(40.123); + expect(result.longitude).toBe(-74.123); + }); + + it(`returns the correct latitude and longitude for 40° 7' 23" -74° 7' 23"`, () => { + const formatParser = new DmsSignedFormat(); + const result = formatParser.parse(`40° 7' 23" -74° 7' 23"`); + expect(result.latitude).toBe(40.123); + expect(result.longitude).toBe(-74.123); + }); + + it(`returns the correct latitude and longitude for 40° 7' 23", -74° 7' 23"`, () => { + const formatParser = new DmsSignedFormat(); + const result = formatParser.parse(`40° 7' 23", -74° 7' 23"`); + expect(result.latitude).toBe(40.123); + expect(result.longitude).toBe(-74.123); + }); + + it(`returns the correct latitude and longitude for 40° 7' 23.9999", -74° 7' 23.9999"`, () => { + const formatParser = new DmsSignedFormat({ precision: 5 }); + const result = formatParser.parse(`40° 7' 23.9999", -74° 7' 23.9999"`); + + expect(result.latitude).toBe(40.12333); + expect(result.longitude).toBe(-74.12333); + }); +});