From a4e04f10591ac28d75e772b3ade6d02946106bc3 Mon Sep 17 00:00:00 2001 From: Trevor Gerhardt Date: Wed, 17 Oct 2018 16:02:30 +0800 Subject: [PATCH] feat(pixel): Add pixel conversion functions --- index.js | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.test.js | 20 ++++++++ package.json | 3 +- 3 files changed, 161 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 8a34b5a..688efd2 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,15 @@ * @property {number} lon */ +/** + * (type) + * + * Object with x/y number values. + * @typedef {Object} lonlat.types.point + * @property {number} x + * @property {number} y + */ + /** * (exception type) * @@ -287,6 +296,136 @@ module.exports.toLatFirstString = function toLatFirstString (input) { return ll.lat + ',' + ll.lon } +/** + * Pixel conversions and constants taken from + * https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames#Implementations + */ + +/** + * Pixels per tile. + */ +var PIXELS_PER_TILE = module.exports.PIXELS_PER_TILE = 256 + +// 2^z represents the tile number. Scale that by the number of pixels in each tile. +function zScale (z) { + return Math.pow(2, z) * PIXELS_PER_TILE +} + +// Converts from degrees to radians +function toRadians (degrees) { + return degrees * Math.PI / 180 +} + +// Converts from radians to degrees. +function toDegrees (radians) { + return radians * 180 / Math.PI +} + +/** + * Convert a longitude to it's pixel value given a `zoom` level. + * + * @param {number} longitude + * @param {number} zoom + * @return {number} pixel + * @example + * var xPixel = lonlat.longitudeToPixel(-70, 9) //= 40049.77777777778 + */ +function longitudeToPixel (longitude, zoom) { + return (longitude + 180) / 360 * zScale(zoom) +} +module.exports.longitudeToPixel = longitudeToPixel + +/** + * Convert a latitude to it's pixel value given a `zoom` level. + * + * @param {number} latitude + * @param {number} zoom + * @return {number} pixel + * @example + * var yPixel = lonlat.latitudeToPixel(40, 9) //= 49621.12736343896 + */ +function latitudeToPixel (latitude, zoom) { + const latRad = toRadians(latitude) + return (1 - + Math.log(Math.tan(latRad) + (1 / Math.cos(latRad))) / + Math.PI) / 2 * zScale(zoom) +} +module.exports.latitudeToPixel = latitudeToPixel + +/** + * Maximum Latitude for valid Mercator projection conversion. + */ +var MAX_LAT = toDegrees(Math.atan(Math.sinh(Math.PI))) + +/** + * Convert a coordinate to a pixel. + * + * @param {lonlat.types.input} input + * @param {number} zoom + * @return {Object} An object with `x` and `y` attributes representing pixel coordinates + * @throws {lonlat.types.InvalidCoordinateException} + * @throws {Error} If latitude is above or below `MAX_LAT` + * @throws {Error} If `zoom` is undefined. + * @example + * var pixel = lonlat.toPixel({lon: -70, lat: 40}, 9) //= {x: 40049.77777777778, y:49621.12736343896} + */ +module.exports.toPixel = function toPixel (input, zoom) { + var ll = normalize(input) + if (ll.lat > MAX_LAT || ll.lat < -MAX_LAT) { + throw new Error('Pixel conversion only works between ' + MAX_LAT + 'N and -' + MAX_LAT + 'S') + } + + return { + x: longitudeToPixel(ll.lon, zoom), + y: latitudeToPixel(ll.lat, zoom) + } +} + +/** + * Convert a pixel to it's longitude value given a zoom level. + * + * @param {number} x + * @param {number} zoom + * @return {number} longitude + * @example + * var lon = lonlat.pixelToLongitude(40000, 9) //= -70.13671875 + */ +function pixelToLongitude (x, zoom) { + return x / zScale(zoom) * 360 - 180 +} +module.exports.pixelToLongitude = pixelToLongitude + +/** + * Convert a pixel to it's latitude value given a zoom level. + * + * @param {number} y + * @param {number} zoom + * @return {number} latitude + * @example + * var lat = lonlat.pixelToLatitude(50000, 9) //= 39.1982053488948 + */ +function pixelToLatitude (y, zoom) { + var latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / zScale(zoom)))) + return toDegrees(latRad) +} +module.exports.pixelToLatitude = pixelToLatitude + +/** + * From pixel. + * + * @param {lonlat.types.point} pixel + * @param {number} zoom + * @return {lonlat.types.output} + * @example + * var ll = lonlat.fromPixel({x: 40000, y: 50000}, 9) //= {lon: -70.13671875, lat: 39.1982053488948} + */ +module.exports.fromPixel = function fromPixel (pixel, zoom) { + return { + lon: pixelToLongitude(pixel.x, zoom), + lat: pixelToLatitude(pixel.y, zoom) + } +} + function floatize (lonlat) { var lon = parseFloatWithAlternates([lonlat.lon, lonlat.lng, lonlat.longitude]) var lat = parseFloatWithAlternates([lonlat.lat, lonlat.latitude]) diff --git a/index.test.js b/index.test.js index dcde96e..d8d32da 100644 --- a/index.test.js +++ b/index.test.js @@ -4,6 +4,8 @@ const ll = require('./') const lat = 38.13234 const lon = 70.01232 +const Z = 9 // Zoom level to use +const pixel = {x: 91026.70779, y: 50497.02600} const lonlat = {lon, lat} const point = {x: lon, y: lat} const coordinates = [lon, lat] @@ -140,6 +142,24 @@ describe('lonlat', () => { }) }) + describe('pixel', () => { + it('can convert to web mercator pixel coordinates', () => { + const p = ll.toPixel({lat, lon}, Z) + expect(Math.round(p.x)).toBe(Math.round(pixel.x)) + expect(Math.round(p.y)).toBe(Math.round(pixel.y)) + }) + + it('can convert from web mercator pixel coordinates', () => { + const l = ll.fromPixel(pixel, Z) + expect(Math.round(l.lat)).toBe(Math.round(lat)) + expect(Math.round(l.lon)).toBe(Math.round(lon)) + }) + + it('should throw an error if converting a latitude > MAX_LAT', () => { + expect(() => ll.toPixel({lat: 86, lon}, Z)).toThrow() + }) + }) + 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 }) diff --git a/package.json b/package.json index c396f09..dfa4e60 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "scripts": { "cover": "yarn test --coverage --coverage-paths index.js", "generate-docs": "documentation readme index.js --section=API --markdown-toc=true", + "jest": "mastarm test", "lint": "mastarm lint index.js index.test.js", "lint-docs": "documentation lint index.js", "pretest": "yarn", "semantic-release": "semantic-release", - "test": "yarn run lint && yarn run lint-docs && mastarm test" + "test": "yarn run lint && yarn run lint-docs && yarn run jest" }, "repository": { "type": "git",