diff --git a/.gitignore b/.gitignore index e920c16..40b0523 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage +tmp # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt diff --git a/.travis.yml b/.travis.yml index 40c649c..3940d3b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,12 @@ notifications: email: false node_js: - '6' +before_install: + - npm i -g codecov +script: + - npm run lint + - npm run cover + - codecov after_success: - npm run semantic-release branches: diff --git a/README.md b/README.md index ac868d8..8df20ba 100644 --- a/README.md +++ b/README.md @@ -7,54 +7,257 @@ Lon/lat normalization cause...**sigh**. No one has agreed on a standard way of representing lon/lat. This is a small normalization library. Use this to convert all outside input before processing internally and convert to an external format right when it's being output. -## Just use the `{lon: ${longitude}, lat: ${latitude}}` representation +## API -Utilizing this won't always be possible/easiest, so please at least adopt the following conventions. Any variables or functions that contain the following names should be represented by the accompanying structure: +### lonlat(input) -* `lonlat`: `{lon: ${longitude}, lat: ${latitude}}` -* `coordinates`: `[${longitude}, ${latitude}]` -* `point`: `{x: ${longitude}, y: ${latitude}}` +Tries parse input and transform to an output of normalized coordinates. Will throw an error upon finding invalid coordinates. -If you must convert it to a string, put it in the following format: +#### Arguments -* `'${longitude},${latitude}'` +`input (*)`: Can be any of the following: +- an array in the format: [longitude, latitude] +- a string in the format: '{longitude},{latitude}' +- an object with a `x` attribute representing `longitude` and a `y` attribute representing `latitude` +- an object with a `lon`, `lng` or `longitude` attribute and a `lat` or `latitude` attribute -## API +#### Returns + +`(Object)`: An object with `lon` and `lat` attributes. + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +// Object with lon/lat-ish attributes +var position = lonlat({ lon: 12, lat: 34 }) // { lon: 12, lat: 34 } +position = lonlat({ lng: 12, lat: 34 }) // { lon: 12, lat: 34 } +position = lonlat({ longitude: 12, latitude: 34 }) // { lon: 12, lat: 34 } +position = lonlat({ lng: 12, latitude: 34 }) // { lon: 12, lat: 34 } + +// coordinate array +position = lonlat([12, 34]) // { lon: 12, lat: 34 } + +// string +position = lonlat('12,34') // { lon: 12, lat: 34 } + +// object with x and y attributes +position = lonlat({ x: 12, y: 34 }) // { lon: 12, lat: 34 } + +// the following will throw errors +position = lonlat({ lon: 999, lat: 34 }) // Error: Invalid longitude value: 999 +position = lonlat({ lon: 12, lat: 999 }) // Error: Invalid latitude value: 999 +position = lonlat({}) // Error: Invalid latitude value: undefined +position = lonlat(null) // Error: Value must not be null or undefined +``` + +### lonlat.fromCoordinates(arr) or lonlat.fromGeoJSON(arr) + +Tries to parse from an array of coordinates. Will throw an error upon finding invalid coordinates. + +#### Arguments + +`arr (Array)`: An array in the format: [longitude, latitude] + +#### Returns + +`(Object)`: An object with `lon` and `lat` attributes. + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var position = lonlat.fromCoordinates([12, 34]) // { lon: 12, lat: 34 } +position = lonlat.fromGeoJSON([12, 34]) // { lon: 12, lat: 34 } +``` + +### lonlat.fromLatlng(obj) or lonlat.fromLeaflet(obj) + +Tries to parse from an object. Will throw an error upon finding invalid coordinates. + +#### Arguments + +`obj (Object)`: An object with a `lon`, `lng` or `longitude` attribute and a `lat` or `latitude` attribute + +#### Returns + +`(Object)`: An object with `lon` and `lat` attributes. + +#### Example ```js -const assert = require('assert') -const ll = require('@conveyal/lonlat') +var lonlat = require('@conveyal/lonlat') + +var position = lonlat.fromLatlng({ longitude: 12, latitude: 34 }) // { lon: 12, lat: 34 } +position = lonlat.fromLeaflet({ lng: 12, lat: 34 }) // { lon: 12, lat: 34 } +``` + +### lonlat.fromPoint(obj) -const lat = 38.13234 -const lon = 70.01232 -const lonlat = {lon, lat} -const point = {x: lon, y: lat} -const coordinates = [lon, lat] -const str = `${lon},${lat}` -const latlng = {lat, lng: lon} +Tries to parse from an object. Will throw an error upon finding invalid coordinates. -const pairs = [ - // normalization - [lonlat, ll(lonlat)], - [lonlat, ll(point)], - [lonlat, ll(coordinates)], - [lonlat, ll(str)], +#### Arguments - // convert to type, normalizes to `latlng` first in each function - [ll.toCoordinates(lonlat), coordinates], - [ll.toPoint(lonlat), point], - [ll.toString(lonlat), str], +`obj (Object)`: An object with a `x` attribute representing `longitude` and a `y` attribute representing `latitude` - // if the type is known, use the specific convert function directly - [lonlat, ll.fromLatlng(latlng)], - [lonlat, ll.fromCoordinates(coordinates)], - [lonlat, ll.fromPoint(point)], - [lonlat, ll.fromString(str)] -] +#### Returns + +`(Object)`: An object with `lon` and `lat` attributes. + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') -pairs.forEach((pair) => assert.deepEqual(pair[0], pair[1])) +var position = lonlat.fromPoint({ x: 12, y: 34 }) // { lon: 12, lat: 34 } ``` +### lonlat.fromString(str) + +Tries to parse from a string. Will throw an error upon finding invalid coordinates. + +#### Arguments + +`str (string)`: A string in the format: '{longitude},{latitude}' + +#### Returns + +`(Object)`: An object with `lon` and `lat` attributes. + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var position = lonlat.fromString('12,34') // { lon: 12, lat: 34 } +``` + +### lonlat.print(input, [fixed=5]) + +Returns a pretty string + +#### Arguments + +- `input (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) +- `[fixed=5] (Number)`: The number of digits to round to + +#### Returns + +`(string)`: A string with the latitude and longitude rounded to the number of decimal places as specified by `fixed` + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var pretty = lonlat.print('12.345678,34') // '12.34568, 34.00000' +``` + +### lonlat.isEqual(lonlat1, lonlat2, [epsilon=0]) + +Checks equality of two inputs within an allowable difference. + +#### Arguments + +- `lonlat1 (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) +- `lonlat2 (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) +- `[epsilon=0] (Number)`: The maximum allowable difference of between each latitude and longitude + +#### Returns + +`(boolean)`: Returns `true` if the inputs are equal or `false` if they are not + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var isEqual = lonlat.isEqual('12,34', [12, 34]) // true +``` + +### lonlat.toCoordinates(input) + +Translates to a coordinate array. + +#### Arguments + +`input (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) + +#### Returns + +`(Array)`: An array in the format: [longitude, latitude] + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var coords = lonlat.toCoordinates('12,34') // [12, 34] +``` + +### lonlat.toPoint(input) + +Translates to point Object. + +#### Arguments + +`input (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) + +#### Returns + +`(Object)`: An object with `x` and `y` attributes representing latitude and longitude respectively + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var point = lonlat.toPoint('12,34') // { x: 12, y: 34 } +``` + +### lonlat.toString(input) + +Translates to coordinate string. + +#### Arguments + +`input (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) + +#### Returns + +`(string)`: A string in the format 'latitude,longitude' + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var str = lonlat.toString({ lat: 12, long: 34 }) // '12,34' +``` + +### lonlat.toLeaflet(input) + +Translates to [Leaflet LatLng](http://leafletjs.com/reference.html#latlng) object. This function requires Leaflet to be installed as a global variable `L` in the window environment. + +#### Arguments + +`input (*)`: Any format mentioned in [lonlat(input)](#lonlatinput) + +#### Returns + +`(Object)`: A [Leaflet LatLng](http://leafletjs.com/reference.html#latlng) object + +#### Example + +```js +var lonlat = require('@conveyal/lonlat') + +var position = lonlat.toLeaflet({ lat: 12, long: 34 }) // Leaflet LatLng object +``` + + [npm-image]: https://img.shields.io/npm/v/@conveyal/lonlat.svg?maxAge=2592000&style=flat-square [npm-url]: https://www.npmjs.com/package/@conveyal/lonlat [travis-image]: https://img.shields.io/travis/conveyal/lonlat.svg?style=flat-square diff --git a/__snapshots__/index.test.js.snap b/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..fd2d2d2 --- /dev/null +++ b/__snapshots__/index.test.js.snap @@ -0,0 +1,11 @@ +exports[`lonlat errors invalid coordinates should throw error when parsing: "-999,999" 1`] = `"Invalid longitude value: -999"`; + +exports[`lonlat errors invalid coordinates should throw error when parsing: "0,999" 1`] = `"Invalid longitude value: 999"`; + +exports[`lonlat errors invalid coordinates should throw error when parsing: {"lng":1,"latitude":1234} 1`] = `"Invalid longitude value: 1234"`; + +exports[`lonlat errors invalid coordinates should throw error when parsing: {} 1`] = `"Invalid longitude value: undefined"`; + +exports[`lonlat errors invalid coordinates should throw error when parsing: undefined 1`] = `"Value must not be null or undefined."`; + +exports[`lonlat errors toLeaflet should throw error if leaflet is not loaded 1`] = `"Leaflet not found."`; diff --git a/index.js b/index.js index e6c63e7..287c5a6 100644 --- a/index.js +++ b/index.js @@ -51,19 +51,37 @@ function fromPoint (point) { } function fromString (str) { - const arr = str.split(',') + var arr = str.split(',') return floatize({lon: arr[0], lat: arr[1]}) } function floatize (lonlat) { - const lon = parseFloat(lonlat.lon || lonlat.lng || lonlat.longitude) - return {lon: lon, lat: parseFloat(lonlat.lat || lonlat.latitude)} + var lon = parseFloatWithAlternates([lonlat.lon, lonlat.lng, lonlat.longitude]) + var lat = parseFloatWithAlternates([lonlat.lat, lonlat.latitude]) + if ((!lon || lon > 180 || lon < -180) && lon !== 0) { + throw new Error(`Invalid longitude value: ${lonlat.lon || lonlat.lng || lonlat.longitude}`) + } + if ((!lat || lat > 90 || lat < -90) && lat !== 0) { + throw new Error(`Invalid longitude value: ${lonlat.lat || lonlat.latitude}`) + } + return {lon: lon, lat: lat} +} + +function parseFloatWithAlternates (alternates) { + if (alternates.length > 0) { + var num = parseFloat(alternates[0]) + if (isNaN(num)) { + return parseFloatWithAlternates(alternates.slice(1)) + } else { + return num + } + } } function normalize (unknown) { if (!unknown) throw new Error('Value must not be null or undefined.') if (Array.isArray(unknown)) return fromCoordinates(unknown) else if (typeof unknown === 'string') return fromString(unknown) - else if (unknown.x && unknown.y) return fromPoint(unknown) + else if ((unknown.x || unknown.x === 0) && (unknown.y || unknown.y === 0)) return fromPoint(unknown) return floatize(unknown) } diff --git a/index.test.js b/index.test.js new file mode 100644 index 0000000..52428fc --- /dev/null +++ b/index.test.js @@ -0,0 +1,133 @@ +/* globals describe, expect, jest, it */ + +const ll = require('./') + +const lat = 38.13234 +const lon = 70.01232 +const lonlat = {lon, lat} +const point = {x: lon, y: lat} +const coordinates = [lon, lat] +const str = `${lon},${lat}` +const latlng = {lat, lng: lon} + +describe('lonlat', () => { + describe('print', () => { + it('should print basic input', () => { + expect(ll.print(str)).toEqual('70.01232, 38.13234') + }) + }) + + describe('isEqual', () => { + it('should not be equal for different coordinates', () => { + expect(ll.isEqual('123.456,78.9', '123.4567,78.9')).toEqual(false) + }) + + it('should be equal for different coordinates with allowable epsilon', () => { + expect(ll.isEqual('123.456,78.9', '123.4567,78.9', 0.001)).toEqual(true) + }) + }) + + describe('toLeaflet', () => { + it('should create leaflet latLng', () => { + window.L = { + latLng: jest.fn((lat, lng) => { return { leaflet_lat: lat, leaflet_lng: lng } }) + } + + expect(ll.toLeaflet('0,0')).toEqual({ leaflet_lat: 0, leaflet_lng: 0 }) + + window.L = undefined + }) + }) + + describe('normalization', () => { + const testCases = [{ + calculated: ll(lonlat), + description: 'Object with lon and lat keys' + }, { + calculated: ll(point), + description: 'Object with x and y keys' + }, { + calculated: ll(coordinates), + description: 'Array of lon and lat' + }, { + calculated: ll(str), + description: 'String with comma separating lon and lat' + }] + + testCases.forEach((test) => { + it(`should normalize from ${test.description}`, () => { + expect(test.calculated).toEqual(lonlat) + }) + }) + }) + + describe('translations', () => { + const testCases = [{ + expected: coordinates, + method: 'toCoordinates' + }, { + expected: point, + method: 'toPoint' + }, { + expected: str, + method: 'toString' + }] + + testCases.forEach((test) => { + it(`should translate using ${test.method}`, () => { + expect(ll[test.method](lonlat)).toEqual(test.expected) + }) + }) + }) + + describe('known type parsing', () => { + const testCases = [{ + calculated: ll.fromLatlng(latlng), + description: 'Object with lng and lat keys' + }, { + calculated: ll.fromCoordinates(coordinates), + description: 'Array of lon and lat' + }, { + calculated: ll.fromPoint(point), + description: 'Object with x and y keys' + }, { + calculated: ll.fromString(str), + description: 'String with comma separating lon and lat' + }] + + testCases.forEach((test) => { + it(`should specifically parse from ${test.description}`, () => { + expect(test.calculated).toEqual(lonlat) + }) + }) + }) + + describe('errors', () => { + it('toLeaflet should throw error if leaflet is not loaded', () => { + expect(() => ll.toLeaflet('0,0')).toThrowErrorMatchingSnapshot() + }) + + describe('invalid coordinates', () => { + const badCoords = [ + '-999,999', + '0,999', + {}, + undefined, + { lng: 1, latitude: 1234 } + ] + + badCoords.forEach((data) => { + it(`should throw error when parsing: ${JSON.stringify(data)}`, () => { + expect(() => ll(data)).toThrowErrorMatchingSnapshot() + }) + }) + }) + }) + + describe('issues', () => { + it('#3 - Does not parse coordinates with 0 for lat or lon', () => { + expect(ll({ lat: 0, lng: 0 })).toEqual({ lat: 0, lon: 0 }) + expect(ll({ x: 0, y: 0 })).toEqual({ lat: 0, lon: 0 }) + }) + }) +}) diff --git a/package.json b/package.json index 87985f1..23a4f4f 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,10 @@ "description": "Lon/lat normalization", "main": "index.js", "scripts": { - "test": "mastarm lint && node test.js", - "semantic-release": "semantic-release pre && npm publish && semantic-release post" + "cover": "npm test -- --coverage --coverage-paths index.js", + "lint": "mastarm lint", + "semantic-release": "semantic-release pre && npm publish && semantic-release post", + "test": "mastarm test" }, "repository": { "type": "git", @@ -24,7 +26,7 @@ }, "homepage": "https://github.com/conveyal/lonlat", "devDependencies": { - "mastarm": "^1.3.0", + "mastarm": "^3.2.1", "semantic-release": "^4.3.5" } } diff --git a/test.js b/test.js deleted file mode 100644 index 69d6e3e..0000000 --- a/test.js +++ /dev/null @@ -1,31 +0,0 @@ -const assert = require('assert') -const ll = require('./') - -const lat = 38.13234 -const lon = 70.01232 -const lonlat = {lon, lat} -const point = {x: lon, y: lat} -const coordinates = [lon, lat] -const str = `${lon},${lat}` -const latlng = {lat, lng: lon} - -const pairs = [ - // normalization - [lonlat, ll(lonlat)], - [lonlat, ll(point)], - [lonlat, ll(coordinates)], - [lonlat, ll(str)], - - // convert to type, normalizes to `latlng` first in each function - [ll.toCoordinates(lonlat), coordinates], - [ll.toPoint(lonlat), point], - [ll.toString(lonlat), str], - - // if the type is known, use the specific convert function directly - [lonlat, ll.fromLatlng(latlng)], - [lonlat, ll.fromCoordinates(coordinates)], - [lonlat, ll.fromPoint(point)], - [lonlat, ll.fromString(str)] -] - -pairs.forEach((pair) => assert.deepEqual(pair[0], pair[1]))