From 98d623514888b538d57c1c78b8d8cb1647ca5b85 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 22 Feb 2024 16:42:14 -0500 Subject: [PATCH] Add package of numerical math functions (closes #269) --- packages/math/README.md | 8 ++++-- packages/math/src/Tiler.ts | 15 ++++++----- packages/math/src/Viewport.ts | 42 ++++++++----------------------- packages/math/src/constants.ts | 20 +++++++++++++++ packages/math/src/geo.ts | 12 ++++----- packages/math/src/index.ts | 2 ++ packages/math/src/number.ts | 38 ++++++++++++++++++++++++++++ packages/math/test/number.test.js | 38 ++++++++++++++++++++++++++++ 8 files changed, 126 insertions(+), 49 deletions(-) create mode 100644 packages/math/src/constants.ts create mode 100644 packages/math/src/number.ts create mode 100644 packages/math/test/number.test.js diff --git a/packages/math/README.md b/packages/math/README.md index 2296a98..a694b40 100644 --- a/packages/math/README.md +++ b/packages/math/README.md @@ -20,12 +20,16 @@ import { Extent } from '@rapid-sdk/math'; ## Packages -- 📦 Extent class for creating bounding boxes +- ⭐️ Math Constants - 🌐 Geographic (spherical) math functions - 📈 Geometric (planar) math functions +- 🔢 Number math functions +- 📐 Vector math functions + +- 📦 Extent class for creating bounding boxes - 🀄️ Tiler class for splitting the world into rectangular tiles - 📺 Viewport class for managing view state and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates -- 📐 Vector (coordinate) math functions + ## Contributing diff --git a/packages/math/src/Tiler.ts b/packages/math/src/Tiler.ts index c7e328c..83357a9 100644 --- a/packages/math/src/Tiler.ts +++ b/packages/math/src/Tiler.ts @@ -8,8 +8,10 @@ import { Extent } from './Extent'; import { Transform, Viewport } from './Viewport'; import { geoScaleToZoom, geoZoomToScale } from './geo'; +import { numClamp } from './number'; import { Vec2, Vec3 } from './vector'; + /** Contains essential information about a tile */ export interface Tile { /** tile identifier string ex. '0,0,0' */ @@ -31,9 +33,6 @@ export interface TileResult { // scale: number; } -function clamp(num: number, min: number, max: number): number { - return Math.max(min, Math.min(num, max)); -} function range(start: number, end: number): number[] { return Array.from(Array(1 + end - start).keys()).map((v) => start + v); @@ -129,7 +128,7 @@ export class Tiler { const scale: number = viewport.scale() as number; const zFrac: number = geoScaleToZoom(scale, this._tileSize); - const z: number = clamp(Math.round(zFrac), this._zoomRange[0], this._zoomRange[1]); + const z: number = numClamp(Math.round(zFrac), this._zoomRange[0], this._zoomRange[1]); const minTile: number = 0; const maxTile: number = Math.pow(2, z) - 1; @@ -148,12 +147,12 @@ export class Tiler { const worldViewport = new Viewport({ x: worldOrigin, y: worldOrigin, k: worldScale }); const cols: number[] = range( - clamp(Math.floor(viewMin[0] / k) - this._margin, minTile, maxTile), - clamp(Math.floor(viewMax[0] / k) + this._margin, minTile, maxTile) + numClamp(Math.floor(viewMin[0] / k) - this._margin, minTile, maxTile), + numClamp(Math.floor(viewMax[0] / k) + this._margin, minTile, maxTile) ); const rows: number[] = range( - clamp(Math.floor(viewMin[1] / k) - this._margin, minTile, maxTile), - clamp(Math.floor(viewMax[1] / k) + this._margin, minTile, maxTile) + numClamp(Math.floor(viewMin[1] / k) - this._margin, minTile, maxTile), + numClamp(Math.floor(viewMax[1] / k) + this._margin, minTile, maxTile) ); let tiles: Tile[] = []; diff --git a/packages/math/src/Viewport.ts b/packages/math/src/Viewport.ts index 8a9d369..bef3354 100644 --- a/packages/math/src/Viewport.ts +++ b/packages/math/src/Viewport.ts @@ -3,35 +3,13 @@ * @module */ +import { TAU, DEG2RAD, RAD2DEG, HALF_PI, MIN_K, MAX_K, MIN_PHI, MAX_PHI } from './constants'; import { Extent } from './Extent'; +import { numClamp, numWrap } from './number'; import { geoZoomToScale } from './geo'; import { geomRotatePoints } from './geom'; import { Vec2, vecRotate } from './vector'; -// constants -const TAU = 2 * Math.PI; -const DEG2RAD = Math.PI / 180; -const RAD2DEG = 180 / Math.PI; -const HALF_PI = Math.PI / 2; - -const TILESIZE = 256; -const MINZOOM = 0; -const MAXZOOM = 24; -const MINK = geoZoomToScale(MINZOOM, TILESIZE); -const MAXK = geoZoomToScale(MAXZOOM, TILESIZE); - -const MAXPHI = 2 * Math.atan(Math.exp(Math.PI)) - HALF_PI; // 85.0511287798 in radians -const MINPHI = -MAXPHI; - -function clamp(num: number, min: number, max: number): number { - return Math.max(min, Math.min(num, max)); -} - -function wrap(num: number, min: number, max: number): number { - const d = max - min; - return ((num - min) % d + d) % d + min; -} - /** The parameters that define the viewport */ export interface Transform { @@ -61,8 +39,8 @@ export class Viewport { this._transform = { x: transform?.x || 0, y: transform?.y || 0, - k: clamp(transform?.k || 256 / Math.PI, MINK, MAXK), // constrain to z0..z24, default z1 - r: wrap(transform?.r || 0, 0, TAU) // constrain to 0..2π + k: numClamp(transform?.k || 256 / Math.PI, MIN_K, MAX_K), // constrain to z0..z24, default z1 + r: numWrap(transform?.r || 0, 0, TAU) // constrain to 0..2π }; this._dimensions = dimensions ? new Extent(dimensions) : new Extent([0, 0], [0, 0]); @@ -82,7 +60,7 @@ export class Viewport { project(loc: Vec2): Vec2 { const { x, y, k, r } = this._transform; const lambda: number = loc[0] * DEG2RAD; - const phi: number = clamp(loc[1] * DEG2RAD, MINPHI, MAXPHI); + const phi: number = numClamp(loc[1] * DEG2RAD, MIN_PHI, MAX_PHI); const mercatorX: number = lambda const mercatorY: number = Math.log(Math.tan((HALF_PI + phi) / 2)); const point: Vec2 = [mercatorX * k + x, y - mercatorY * k]; @@ -110,7 +88,7 @@ export class Viewport { point = vecRotate(point, -r, this._dimensions.center()); } const mercatorX: number = (point[0] - x) / k; - const mercatorY: number = clamp((y - point[1]) / k, -Math.PI, Math.PI); + const mercatorY: number = numClamp((y - point[1]) / k, -Math.PI, Math.PI); const lambda: number = mercatorX; const phi: number = 2 * Math.atan(Math.exp(mercatorY)) - HALF_PI; return [lambda * RAD2DEG, phi * RAD2DEG]; @@ -145,7 +123,7 @@ export class Viewport { */ scale(val?: number): number | Viewport { if (val === undefined) return this._transform.k; - this._transform.k = clamp(+val, MINK, MAXK); // constrain to z0..z24 + this._transform.k = numClamp(+val, MIN_K, MAX_K); // constrain to z0..z24 return this; } @@ -162,7 +140,7 @@ export class Viewport { */ rotate(val?: number): number | Viewport { if (val === undefined) return this._transform.r; - this._transform.r = wrap(+val, 0, TAU); // constrain to 0..2π + this._transform.r = numWrap(+val, 0, TAU); // constrain to 0..2π return this; } @@ -182,8 +160,8 @@ export class Viewport { if (obj.x !== undefined) this._transform.x = +obj.x; if (obj.y !== undefined) this._transform.y = +obj.y; - if (obj.k !== undefined) this._transform.k = clamp(+obj.k, MINK, MAXK); // constrain to z0..z24 - if (obj.r !== undefined) this._transform.r = wrap(+obj.r, 0, TAU); // constrain to 0..2π + if (obj.k !== undefined) this._transform.k = numClamp(+obj.k, MIN_K, MAX_K); // constrain to z0..z24 + if (obj.r !== undefined) this._transform.r = numWrap(+obj.r, 0, TAU); // constrain to 0..2π return this; } diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts new file mode 100644 index 0000000..939a209 --- /dev/null +++ b/packages/math/src/constants.ts @@ -0,0 +1,20 @@ +/** + * Constants + * @module + */ + +export const TAU = 2 * Math.PI; +export const DEG2RAD = Math.PI / 180; +export const RAD2DEG = 180 / Math.PI; +export const HALF_PI = Math.PI / 2; + +export const MIN_Z = 0; +export const MAX_Z = 24; +export const MIN_K = (256 * Math.pow(2, MIN_Z)) / TAU; +export const MAX_K = (256 * Math.pow(2, MAX_Z)) / TAU; + +export const MAX_PHI = 2 * Math.atan(Math.exp(Math.PI)) - HALF_PI; // 85.0511287798 in radians +export const MIN_PHI = -MAX_PHI; + +export const EQUATORIAL_RADIUS = 6378137.0; +export const POLAR_RADIUS = 6356752.314245179; diff --git a/packages/math/src/geo.ts b/packages/math/src/geo.ts index ae13b1d..ab5c71e 100644 --- a/packages/math/src/geo.ts +++ b/packages/math/src/geo.ts @@ -3,14 +3,9 @@ * @module */ +import { TAU, DEG2RAD, POLAR_RADIUS, EQUATORIAL_RADIUS } from './constants'; import { Vec2 } from './vector'; -// constants -const TAU = 2 * Math.PI; -const DEG2RAD = Math.PI / 180; -const EQUATORIAL_RADIUS = 6378137.0; -const POLAR_RADIUS = 6356752.314245179; - /** Convert degrees latitude to meters. * @param dLat degrees latitude @@ -75,7 +70,10 @@ export function geoMetersToLon(m: number, atLat: number): number { * ``` */ export function geoMetersToOffset(m: Vec2, tileSize: number = 256): Vec2 { - return [(m[0] * tileSize) / (TAU * EQUATORIAL_RADIUS), (-m[1] * tileSize) / (TAU * POLAR_RADIUS)]; + return [ + (m[0] * tileSize) / (TAU * EQUATORIAL_RADIUS), + (-m[1] * tileSize) / (TAU * POLAR_RADIUS) + ]; } diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts index 0ced653..70237f3 100644 --- a/packages/math/src/index.ts +++ b/packages/math/src/index.ts @@ -7,6 +7,8 @@ export * from './Extent'; export * from './Tiler'; export * from './Viewport'; +export * from './constants'; export * from './geo'; export * from './geom'; +export * from './number'; export * from './vector'; diff --git a/packages/math/src/number.ts b/packages/math/src/number.ts new file mode 100644 index 0000000..b4c271a --- /dev/null +++ b/packages/math/src/number.ts @@ -0,0 +1,38 @@ +/** + * 🔢 Numeric math functions + * @module + */ + + +/** Clamp a number within a min..max range + * @param num + * @param min + * @param max + * @returns result + * @example ``` + * numClamp(-1, 0, 10); // returns 0, (below min) + * numClamp(5, 0, 10); // returns 5, (in range) + * numClamp(11, 0, 10); // returns 10, (above max) + * ``` + */ +export function numClamp(num: number, min: number, max: number): number { + return Math.max(min, Math.min(num, max)); +} + + +/** Wrap a number around a min..max range + * Similar to modulo, but works for negative numbers too. + * @param num + * @param min + * @param max + * @returns result + * @example ``` + * numWrap(-1, 0, 10); // returns 9, (below min) + * numWrap(5, 0, 10); // returns 5, (in range) + * numWrap(11, 0, 10); // returns 1, (above max) + * ``` + */ +export function numWrap(num: number, min: number, max: number): number { + const d = max - min; + return ((num - min) % d + d) % d + min; +} diff --git a/packages/math/test/number.test.js b/packages/math/test/number.test.js new file mode 100644 index 0000000..8f5f19a --- /dev/null +++ b/packages/math/test/number.test.js @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import * as test from '../built/math.mjs'; + + +assert.closeTo = function(a, b, epsilon = 1e-6) { + if (Math.abs(a - b) > epsilon) { + assert.fail(`${a} is not close to ${b} within ${epsilon}`); + } +} + +describe('math/number', () => { + describe('numClamp', () => { + it('clamps integers within a min..max range', () => { + assert.equal(test.numClamp(-1, 0, 10), 0); + assert.equal(test.numClamp(5, 0, 10), 5); + assert.equal(test.numClamp(11, 0, 10), 10); + }); + it('clamps floats within a min..max range', () => { + assert.equal(test.numClamp(-Math.PI, 0, 2 * Math.PI), 0); + assert.equal(test.numClamp(Math.PI, 0, 2 * Math.PI), Math.PI); + assert.equal(test.numClamp(3 * Math.PI, 0, 2 * Math.PI), 2 * Math.PI); + }); + }); + + describe('numWrap', () => { + it('wraps integers around a min..max range', () => { + assert.equal(test.numWrap(-1, 0, 10), 9); + assert.equal(test.numWrap(5, 0, 10), 5); + assert.equal(test.numWrap(11, 0, 10), 1); + }); + it('wraps floats around a min..max range', () => { + assert.closeTo(test.numWrap(-Math.PI, 0, 2 * Math.PI), Math.PI); + assert.closeTo(test.numWrap(Math.PI, 0, 2 * Math.PI), Math.PI); + assert.closeTo(test.numWrap(3 * Math.PI, 0, 2 * Math.PI), Math.PI); + }); + }); +});