Skip to content

Commit

Permalink
Add package of numerical math functions
Browse files Browse the repository at this point in the history
(closes #269)
  • Loading branch information
bhousel committed Feb 22, 2024
1 parent 852d6f2 commit 98d6235
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 49 deletions.
8 changes: 6 additions & 2 deletions packages/math/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions packages/math/src/Tiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' */
Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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[] = [];
Expand Down
42 changes: 10 additions & 32 deletions packages/math/src/Viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]);
Expand All @@ -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];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down
20 changes: 20 additions & 0 deletions packages/math/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 5 additions & 7 deletions packages/math/src/geo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
];
}


Expand Down
2 changes: 2 additions & 0 deletions packages/math/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
38 changes: 38 additions & 0 deletions packages/math/src/number.ts
Original file line number Diff line number Diff line change
@@ -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;
}
38 changes: 38 additions & 0 deletions packages/math/test/number.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

0 comments on commit 98d6235

Please sign in to comment.