Skip to content

Commit

Permalink
feat: implement DmsSignedFormat for parsing DMS signed coordinates an…
Browse files Browse the repository at this point in the history
…d add related tests
  • Loading branch information
reskume committed Dec 4, 2024
1 parent 89badb1 commit d4db56a
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 6 deletions.
64 changes: 58 additions & 6 deletions src/formats/base-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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');
}
Expand All @@ -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('-')) {
Expand All @@ -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') {
Expand Down
78 changes: 78 additions & 0 deletions src/formats/dms-signed-format.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
61 changes: 61 additions & 0 deletions tests/dms-signed-format.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit d4db56a

Please sign in to comment.