diff --git a/README.md b/README.md index 4f82d30..d7dce53 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Includes: - 📦 Extent class for working with bounding boxes - 🀄️ Tiler class for splitting the world into rectangular tiles -- 🕹️ Transform class for managing translation, scale, rotation -- 📺 Viewport class for managing view state and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates +- 🕹️ Transform class for managing translation, zoom, rotation +- 📺 Viewport class for managing view state and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates ### util diff --git a/bun.lock b/bun.lock index ee1a9f8..d7d4559 100644 --- a/bun.lock +++ b/bun.lock @@ -4,9 +4,6 @@ "workspaces": { "": { "name": "rapid-sdk", - "dependencies": { - "@types/geojson": "^7946.0.16", - }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/bun": "^1.3.13", @@ -21,6 +18,7 @@ "version": "1.0.0-pre.4", "dependencies": { "@types/d3-polygon": "^3.0.2", + "@types/geojson": "^7946.0.16", "d3-polygon": "^3.0.1", }, }, diff --git a/docs/classes/_rapid_sdk_math.Viewport.html b/docs/classes/_rapid_sdk_math.Viewport.html index 23fe196..a31eb3c 100644 --- a/docs/classes/_rapid_sdk_math.Viewport.html +++ b/docs/classes/_rapid_sdk_math.Viewport.html @@ -1,7 +1,7 @@ Viewport | rapid-sdk

Viewport is a class for managing the state of the viewer -and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates

+and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates

Original geographic coordinate data is in WGS84 (Lon,Lat) -and "projected" into screen space (x,y) using the Web Mercator projection +and "projected" into screen space [x,y] using the Web Mercator projection see: https://en.wikipedia.org/wiki/Web_Mercator_projection

The parameters of this projection are stored in _transform

    @@ -57,15 +57,15 @@
  • Projects a coordinate from Lon/Lat (λ,φ) to Cartesian (x,y)

    -

    Parameters

    • loc: Vec2

      Lon/Lat (λ,φ)

      -
    • OptionalincludeRotation: boolean

    Returns Vec2

    Cartesian (x,y)

    +

🧳 @rapid-sdk/util

diff --git a/docs/modules/_rapid_sdk_math.html b/docs/modules/_rapid_sdk_math.html index f567b81..7163089 100644 --- a/docs/modules/_rapid_sdk_math.html +++ b/docs/modules/_rapid_sdk_math.html @@ -33,7 +33,7 @@

🕹️ Transform class for managing translation, scale, rotation

  • -

    📺 Viewport class for managing view state and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates

    +

    📺 Viewport class for managing view state and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates

  • This project is just getting started! 🌱

    diff --git a/package.json b/package.json index 59446f5..22c26e1 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,6 @@ "packages/math", "packages/util" ], - "dependencies": { - "@types/geojson": "^7946.0.16" - }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/bun": "^1.3.13", diff --git a/packages/math/README.md b/packages/math/README.md index a6b7c92..23caba4 100644 --- a/packages/math/README.md +++ b/packages/math/README.md @@ -31,8 +31,8 @@ import { Extent } from '@rapid-sdk/math'; // ESM import named - 📦 Extent class for working with bounding boxes - 🀄️ Tiler class for splitting the world into rectangular tiles -- 🕹️ Transform class for managing translation, scale, rotation -- 📺 Viewport class for managing view state and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates +- 🕹️ Transform class for managing translation, zoom, rotation +- 📺 Viewport class for managing view state and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates ## Contributing diff --git a/packages/math/package.json b/packages/math/package.json index 513135f..86d17ae 100644 --- a/packages/math/package.json +++ b/packages/math/package.json @@ -39,6 +39,7 @@ "test": "bun test --dots --coverage ./test" }, "dependencies": { + "@types/geojson": "^7946.0.16", "@types/d3-polygon": "^3.0.2", "d3-polygon": "^3.0.1" }, diff --git a/packages/math/src/Extent.ts b/packages/math/src/Extent.ts index f2e64b8..3b81193 100644 --- a/packages/math/src/Extent.ts +++ b/packages/math/src/Extent.ts @@ -4,7 +4,7 @@ */ import { geoMetersToLat, geoMetersToLon } from './geo'; -import { Vec2 } from './vector'; +import { Vec2, Vec4 } from './vector'; /** Bounding box containing minX, minY, maxX, maxY numbers */ export interface BBox { @@ -97,7 +97,7 @@ export class Extent { * @example * new Extent([0, 0], [5, 10]).rectangle(); // returns [0, 0, 5, 10] */ - rectangle(): number[] { + rectangle(): Vec4 { return [this.min[0], this.min[1], this.max[0], this.max[1]]; } @@ -243,11 +243,15 @@ export class Extent { */ extendSelf(other: Extent | Vec2): Extent { if (other instanceof Extent) { - this.min = [Math.min(other.min[0], this.min[0]), Math.min(other.min[1], this.min[1])]; - this.max = [Math.max(other.max[0], this.max[0]), Math.max(other.max[1], this.max[1])]; + this.min[0] = Math.min(other.min[0], this.min[0]); + this.min[1] = Math.min(other.min[1], this.min[1]); + this.max[0] = Math.max(other.max[0], this.max[0]); + this.max[1] = Math.max(other.max[1], this.max[1]); } else { - this.min = [Math.min(other[0], this.min[0]), Math.min(other[1], this.min[1])]; - this.max = [Math.max(other[0], this.max[0]), Math.max(other[1], this.max[1])]; + this.min[0] = Math.min(other[0], this.min[0]); + this.min[1] = Math.min(other[1], this.min[1]); + this.max[0] = Math.max(other[0], this.max[0]); + this.max[1] = Math.max(other[1], this.max[1]); } return this; } diff --git a/packages/math/src/Tiler.ts b/packages/math/src/Tiler.ts index f99a75a..478bfc8 100644 --- a/packages/math/src/Tiler.ts +++ b/packages/math/src/Tiler.ts @@ -5,11 +5,11 @@ * See: https://developers.google.com/maps/documentation/javascript/coordinates */ -import { MAX_Z, MIN_Z } from './constants'; +import { ANGLE_EPSILON, MAX_Z, MIN_Z, TAU, WORLD_SIZE } from './constants'; import { Extent } from './Extent'; import { Viewport } from './Viewport'; import { geomPathHasIntersections, geomPolygonIntersectsPolygon, geomRotatePoints } from './geom'; -import { numClamp } from './number'; +import { numClamp, numWrap } from './number'; import { Vec2, Vec3 } from './vector'; import type * as GeoJSON from 'geojson'; @@ -21,7 +21,7 @@ export interface Tile { id: string; /** tile coordinate array ex. [0,0,0] */ xyz: Vec3; - /** Extent in world coordinates (z16 scale, range 0..16_777_216) */ + /** Extent in world coordinates (z=WORLD_ZOOM scale, range 0..WORLD_SIZE) */ tileExtent: Extent; /** Extent in WGS84 coordinates(lon,lat) */ wgs84Extent: Extent; @@ -35,12 +35,6 @@ export interface TileResult { tiles: Tile[]; } - -function range(start: number, end: number): number[] { - return Array.from(Array(1 + end - start).keys()).map((v) => start + v); -} - - export class Tiler { private _tileSize = 256; private _zoomRange: Vec2 = [MIN_Z, MAX_Z]; @@ -145,9 +139,12 @@ export class Tiler { // ''--..__ / // ''G [vw,vh] - const ts = this._tileSize; // tile size in pizels + const ts = this._tileSize; // tile size in pixels const ms = this._margin * ts; // margin size in pixels const t = viewport.transform.props; + const wrappedRotation = numWrap(t.r ?? 0, 0, TAU); + const rotation = (wrappedRotation < ANGLE_EPSILON || (TAU - wrappedRotation) < ANGLE_EPSILON) ? 0 : wrappedRotation; + const hasRotation = rotation !== 0; const [sw, sh] = viewport.dimensions; let visiblePolygon = viewport.visiblePolygon() as Vec2[]; let screenPolygon = [[0, 0], [0, sh], [sw, sh], [sw, 0], [0, 0]] as Vec2[]; @@ -158,17 +155,17 @@ export class Tiler { let marginPolygon = _addMargin(screenPolygon, ms); // Un-rotate the polygons back to where they would be on a north-aligned grid. - if (t.r) { + if (hasRotation) { const center = viewport.center(); - screenPolygon = geomRotatePoints(screenPolygon, -t.r, center); - marginPolygon = geomRotatePoints(marginPolygon, -t.r, center); - visiblePolygon = geomRotatePoints(visiblePolygon, -t.r, center); + screenPolygon = geomRotatePoints(screenPolygon, -rotation, center); + marginPolygon = geomRotatePoints(marginPolygon, -rotation, center); + visiblePolygon = geomRotatePoints(visiblePolygon, -rotation, center); } if (ms) { // now that visible is un-rotated, we can apply margin to it if needed. visiblePolygon = _addMargin(visiblePolygon, ms); } - // Convert all polygons to world coordinates.. + // Convert all polygons to world coordinates. for (let i = 0; i < 5; i++) { visiblePolygon[i] = viewport.screenToWorld(visiblePolygon[i]); screenPolygon[i] = viewport.screenToWorld(screenPolygon[i]); @@ -184,24 +181,21 @@ export class Tiler { const adjust = log2ts - 8; // adjust zoom for tile sizes not 256px (log2(256) = 8) const zOrig = t.z - adjust; const z = numClamp(Math.round(zOrig), this._zoomRange[0], this._zoomRange[1]); - const pow2z = Math.pow(2, z); - const worldScale = pow2z / 16_777_216; - const tileScale = 16_777_216 / pow2z; + const pow2z = 2 ** z; + const worldScale = pow2z / WORLD_SIZE; + const tileScale = WORLD_SIZE / pow2z; const min = 0; const max = pow2z - 1; - const cols = range( - numClamp(Math.floor(worldMin[0] * worldScale), min, max), - numClamp(Math.floor(worldMax[0] * worldScale), min, max) - ); - const rows = range( - numClamp(Math.floor(worldMin[1] * worldScale), min, max), - numClamp(Math.floor(worldMax[1] * worldScale), min, max) - ); - - const tiles: Tile[] = []; - for (const y of rows) { - for (const x of cols) { + const minCol = numClamp(Math.floor(worldMin[0] * worldScale), min, max); + const maxCol = numClamp(Math.floor(worldMax[0] * worldScale), min, max); + const minRow = numClamp(Math.floor(worldMin[1] * worldScale), min, max); + const maxRow = numClamp(Math.floor(worldMax[1] * worldScale), min, max); + + const visibleTiles: Tile[] = []; + const marginTiles: Tile[] = []; + for (let y = minRow; y <= maxRow; y++) { + for (let x = minCol; x <= maxCol; x++) { if (this._skipNullIsland && Tiler.isNearNullIsland(x, y, z)) continue; // The tile bounds in world coordinates @@ -218,7 +212,7 @@ export class Tiler { // Note, for rotated viewports, we need the strict test, // because the tile corners may be rotated out of the viewport - see rapid-sdk#281 - if (!isIncluded && t.r !== 0) { + if (!isIncluded && hasRotation) { isIncluded = geomPathHasIntersections(marginPolygon, tilePolygon); } if (!isIncluded) continue; // no need to include this tile in the result @@ -228,7 +222,7 @@ export class Tiler { geomPolygonIntersectsPolygon(screenPolygon, tilePolygon, false) || geomPolygonIntersectsPolygon(tilePolygon, screenPolygon, false); - if (!isVisible && t.r !== 0) { + if (!isVisible && hasRotation) { isVisible = geomPathHasIntersections(screenPolygon, tilePolygon); } @@ -247,15 +241,15 @@ export class Tiler { }; if (isVisible) { - tiles.unshift(tile); // tiles in view at beginning + visibleTiles.push(tile); // tiles in view at beginning } else { - tiles.push(tile); // tiles in margin at the end + marginTiles.push(tile); // tiles in margin at the end } } } return { - tiles: tiles + tiles: visibleTiles.reverse().concat(marginTiles) }; @@ -279,7 +273,7 @@ export class Tiler { * @example * const t = new Tiler(); * const v = new Viewport(); - * v.transform = { x: 256, y: 256, k: 256 / Math.PI }; // z1 + * v.transform = { x: 256, y: 256, z: 1 }; * v.dimensions = [512, 512]; // entire world visible * const result = t.getTiles(v); * const gj = t.getGeoJSON(result); // returns a GeoJSON FeatureCollection @@ -400,8 +394,8 @@ export class Tiler { */ static isNearNullIsland(x: number, y: number, z: number): boolean { if (z >= 7) { - const center = Math.pow(2, z - 1); - const width = Math.pow(2, z - 6); + const center = 2 ** (z - 1); + const width = 2 ** (z - 6); const min = center - width / 2; const max = center + width / 2 - 1; return x >= min && x <= max && y >= min && y <= max; diff --git a/packages/math/src/Transform.ts b/packages/math/src/Transform.ts index 3353188..0507621 100644 --- a/packages/math/src/Transform.ts +++ b/packages/math/src/Transform.ts @@ -3,7 +3,7 @@ * @module */ -import { TAU, MIN_Z, MAX_Z } from './constants'; +import { TAU, MIN_Z, MAX_Z, ANGLE_EPSILON } from './constants'; import { numClamp, numWrap } from './number'; import { Vec2 } from './vector'; @@ -37,7 +37,7 @@ export class Transform { /** Constructs a new Transform * @param other */ - constructor(other?: Transform) { + constructor(other?: Partial) { if (other) { this.props = other; } @@ -123,6 +123,9 @@ export class Transform { let r = +val.r; if (!isNaN(r) && isFinite(r)) { r = numWrap(r, 0, TAU); // wrap to 0..2π + if (r < ANGLE_EPSILON || (TAU - r) < ANGLE_EPSILON) { + r = 0; + } if (this.r !== r) { this.r = r; changed = true; diff --git a/packages/math/src/Viewport.ts b/packages/math/src/Viewport.ts index 26c91de..92220df 100644 --- a/packages/math/src/Viewport.ts +++ b/packages/math/src/Viewport.ts @@ -1,34 +1,27 @@ /** - * 📺 Viewport module for managing view state and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates + * 📺 Viewport module for managing view state and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates * @module */ -import { TAU, DEG2RAD, RAD2DEG, HALF_PI, MAX_PHI, MIN_PHI } from './constants'; +import { TAU, DEG2RAD, RAD2DEG, HALF_PI, MAX_PHI, MIN_PHI, WORLD_HALF, WORLD_SIZE, WORLD_ZOOM, ANGLE_EPSILON } from './constants'; import { Extent } from './Extent'; import { numClamp, numWrap } from './number'; import { Transform, TransformProps } from './Transform'; import { Vec2, vecRotate, vecScale, vecCeil } from './vector'; -/** World size in world coordinates: 256 × 2^16 - * World coordinates are pre-scaled to zoom 16, so the full world spans [0, 0] to [WORLD_SIZE, WORLD_SIZE]. - */ -const WORLD_SIZE = 256 * 65536; // 16_777_216 -const WORLD_HALF = WORLD_SIZE / 2; // 8_388_608 — the Mercator origin (Null Island) - - /** `Viewport` is a class for managing the state of the viewer - * and converting between Lon/Lat (λ,φ) and Cartesian (x,y) coordinates + * and converting between Lon/Lat [λ,φ] and Cartesian [x,y] coordinates * * Original geographic coordinate data is in WGS84 (Lon,Lat) - * and "projected" into screen space (x,y) using the Web Mercator projection + * and "projected" into screen space [x,y] using the Web Mercator projection * see: https://en.wikipedia.org/wiki/Web_Mercator_projection * * Some nomenclature on the coordinates that this code uses: - * - "WGS84 coordinates" - These are Lon/Lat (λ,φ) - * - "world coordinates" - These are Mercator projected into Cartesian (x,y) and pre-scaled to zoom 16 - * - origin puts [0,0] at top left of world and [16_777_216, 16_777_216] (256 × 2^16) at bottom right. - * - pre-scaling to z16 means geometry projected once can be rendered at any zoom without recalculating. + * - "WGS84 coordinates" - These are Lon/Lat [λ,φ] + * - "world coordinates" - These are Mercator projected into Cartesian [x,y] and pre-scaled to WORLD_ZOOM + * - origin puts [0,0] at top left of world and [WORLD_SIZE, WORLD_SIZE] (256 × 2^WORLD_ZOOM) at bottom right. + * - pre-scaling to z=WORLD_ZOOM means geometry projected once can be rendered at any zoom without recalculating. * - "screen coordinates" - These are the Cartesian coordinates with view transform applied * - origin puts [0,0] at top left of screen and they are in pixels * @@ -87,10 +80,10 @@ export class Viewport { } - /** Projects a coordinate from Lon/Lat (λ,φ) to Cartesian (x,y) - * @param loc Lon/Lat (λ,φ) + /** Projects a coordinate from Lon/Lat [λ,φ] to Cartesian [x,y] + * @param loc Lon/Lat [λ,φ] * @param includeRotation - if true, consider rotation when working with the screen coordinate - * @returns Cartesian (x,y) + * @returns Cartesian [x,y] * @example * const v = new Viewport(); * v.project([0, 0]); // returns [0, 0] @@ -99,26 +92,13 @@ export class Viewport { */ project(loc: Vec2, includeRotation?: boolean): Vec2 { return this.worldToScreen(this.wgs84ToWorld(loc), includeRotation); -// const { x, y, k, r } = this._transform; -// -// const lambda = loc[0] * DEG2RAD; -// const phi = numClamp(loc[1] * DEG2RAD, MIN_PHI, MAX_PHI); -// const mercatorX = lambda; -// const mercatorY = Math.log(Math.tan((HALF_PI + phi) / 2)); -// const point: Vec2 = [mercatorX * k + x, y - mercatorY * k]; -// -// if (includeRotation && r) { -// return vecRotate(point, r, this.center()); -// } else { -// return point; -// } } - /** Unprojects a coordinate from given Cartesian (x,y) to Lon/Lat (λ,φ) - * @param point Cartesian (x,y) + /** Unprojects a coordinate from given Cartesian [x,y] to Lon/Lat [λ,φ] + * @param point Cartesian [x,y] * @param includeRotation - if true, consider rotation when working with the screen coordinate - * @returns Lon/Lat (λ,φ) + * @returns Lon/Lat [λ,φ] * @example * const v = new Viewport(); * v.unproject([0, 0]); // returns [0, 0] @@ -127,25 +107,13 @@ export class Viewport { */ unproject(point: Vec2, includeRotation?: boolean): Vec2 { return this.worldToWgs84(this.screenToWorld(point, includeRotation)); -// const { x, y, k, r } = this._transform; -// -// if (includeRotation && r) { -// point = vecRotate(point, -r, this.center()); -// } -// -// const mercatorX = (point[0] - x) / k; -// const mercatorY = numClamp((y - point[1]) / k, -Math.PI, Math.PI); -// const lambda = mercatorX; -// const phi = 2 * Math.atan(Math.exp(mercatorY)) - HALF_PI; -// -// return [lambda * RAD2DEG, phi * RAD2DEG]; } - /** Converts from Lon/Lat (λ,φ) WGS84 coordinate to Cartesian (x,y) world coordinate + /** Converts from Lon/Lat [λ,φ] WGS84 coordinate to Cartesian [x,y] world coordinate * @see https://gis.stackexchange.com/questions/66247/what-is-the-formula-for-calculating-world-coordinates-for-a-given-latlng-in-goog - * @param loc - The WGS84 coordinate Lon/Lat (λ,φ) - * @returns The world coordinate (x,y) + * @param loc - The WGS84 coordinate Lon/Lat [λ,φ] + * @returns The world coordinate [x,y] */ wgs84ToWorld(loc: Vec2): Vec2 { const x = (loc[0] + 180) / 360 * WORLD_SIZE; @@ -155,10 +123,10 @@ export class Viewport { } - /** Converts from Cartesian (x,y) world coordinate to Lon/Lat (λ,φ) WGS84 coordinate + /** Converts from Cartesian [x,y] world coordinate to Lon/Lat [λ,φ] WGS84 coordinate * @see https://gis.stackexchange.com/questions/66247/what-is-the-formula-for-calculating-world-coordinates-for-a-given-latlng-in-goog - * @param world - The world coordinate (x,y) - * @returns The WGS84 coordinate Lon/Lat (λ,φ) + * @param world - The world coordinate [x,y] + * @returns The WGS84 coordinate Lon/Lat [λ,φ] */ worldToWgs84(world: Vec2): Vec2 { const lon = (world[0] / WORLD_SIZE) * 360 - 180; @@ -170,20 +138,20 @@ export class Viewport { /** Converts from world coordinate to screen coordinate applying view transform - * @param world - the world coordinate (x,y) + * @param world - the world coordinate [x,y] * @param includeRotation - if true, consider rotation when working with the screen coordinate - * @returns The screen coordinate (x,y) + * @returns The screen coordinate [x,y] */ worldToScreen(world: Vec2, includeRotation?: boolean): Vec2 { const { x, y, z, r } = this._transform; - const scale = Math.pow(2, z - 16); // world coords are pre-scaled to z16 + const scale = 2 ** (z - WORLD_ZOOM); // world coords are pre-scaled to WORLD_ZOOM const point: Vec2 = [ ((world[0] - WORLD_HALF) * scale) + x, ((world[1] - WORLD_HALF) * scale) + y ]; - if (includeRotation && r) { + if (includeRotation && Math.abs(r) > ANGLE_EPSILON) { return vecRotate(point, r, this.center()); } else { return point; @@ -192,18 +160,18 @@ export class Viewport { /** Converts from screen coordinate to world coordinate applying view transform - * @param screen - the screen coordinate (x,y) + * @param screen - the screen coordinate [x,y] * @param includeRotation - if true, consider rotation when working with the screen coordinate - * @returns The world coordinate (x,y) + * @returns The world coordinate [x,y] */ screenToWorld(screen: Vec2, includeRotation?: boolean): Vec2 { const { x, y, z, r } = this._transform; - if (includeRotation && r) { + if (includeRotation && Math.abs(r) > ANGLE_EPSILON) { screen = vecRotate(screen, -r, this.center()); } - const scale = Math.pow(2, z - 16); // world coords are pre-scaled to z16 + const scale = 2 ** (z - WORLD_ZOOM); // world coords are pre-scaled to WORLD_ZOOM const point: Vec2 = [ ((screen[0] - x) / scale) + WORLD_HALF, ((screen[1] - y) / scale) + WORLD_HALF @@ -214,12 +182,12 @@ export class Viewport { /** Sets/Gets a transform object * @param val a Transform-like object containing the new Transform properties - * @returns When argument is provided, sets `x`,`y`,`k`,`r` from the Transform and returns `this` for method chaining. - * Returns a Transform object containing the current `x`,`y`,`k`,`r` values otherwise + * @returns When argument is provided, sets `x`,`y`,`z`,`r` from the Transform and returns `this` for method chaining. + * Returns a Transform object containing the current `x`,`y`,`z`,`r` values otherwise * @example - * const t = { x: 20, y: 30, k: 512 / Math.PI, r: Math.PI / 2 }; + * const t = { x: 20, y: 30, z: 1, r: Math.PI / 2 }; * const v = new Viewport(); - * v.transform = t; // sets transform `x`,`y`,`k`,`r` from given Object + * v.transform = t; // sets transform `x`,`y`,`z`,`r` from given Object * v.transform; // gets transform */ set transform(val: Partial) { @@ -300,19 +268,22 @@ export class Viewport { */ visiblePolygon(): Vec2[] { const [w, h] = this._dimensions; - const r = numWrap(this._transform.r, 0, TAU); // just in case, wrap to 0..2π + const wrapped = numWrap(this._transform.r, 0, TAU); // just in case, wrap to 0..2π + const r = (wrapped < ANGLE_EPSILON || (TAU - wrapped) < ANGLE_EPSILON) ? 0 : wrapped; if (r) { const sinr = Math.abs(Math.sin(r)); const cosr = Math.abs(Math.cos(r)); + const sin = sinr < ANGLE_EPSILON ? 0 : sinr; + const cos = cosr < ANGLE_EPSILON ? 0 : cosr; - const ae = w * sinr; - const af = h * cosr; + const ae = w * sin; + const af = h * cos; - const ex = ae * sinr; - const ey = ae * cosr; - const fx = af * sinr; - const fy = af * cosr; + const ex = ae * sin; + const ey = ae * cos; + const fx = af * sin; + const fy = af * cos; let E, F, G, H; @@ -351,14 +322,17 @@ export class Viewport { */ visibleDimensions(): Vec2 { const [w, h] = this._dimensions; - const r = this._transform.r; + const wrapped = numWrap(this._transform.r, 0, TAU); + const r = (wrapped < ANGLE_EPSILON || (TAU - wrapped) < ANGLE_EPSILON) ? 0 : wrapped; if (r) { const sinr = Math.abs(Math.sin(r)); const cosr = Math.abs(Math.cos(r)); + const sin = sinr < ANGLE_EPSILON ? 0 : sinr; + const cos = cosr < ANGLE_EPSILON ? 0 : cosr; - const w2 = w * cosr + h * sinr; // ed + fb - const h2 = h * cosr + w * sinr; // af + ae + const w2 = w * cos + h * sin; // ed + fb + const h2 = h * cos + w * sin; // af + ae return vecCeil([w2, h2]); } else { diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts index 1c2e0ea..62f4816 100644 --- a/packages/math/src/constants.ts +++ b/packages/math/src/constants.ts @@ -7,10 +7,16 @@ 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 QUARTER_PI = Math.PI / 4; +export const ANGLE_EPSILON = 1e-12; export const MIN_Z = 0; export const MAX_Z = 24; +export const WORLD_ZOOM = 16; +export const WORLD_SIZE = 256 * (2 ** WORLD_ZOOM); // 16_777_216 +export const WORLD_HALF = WORLD_SIZE / 2; // 8_388_608 — the Mercator origin (Null Island) + export const MAX_PHI = 2 * Math.atan(Math.exp(Math.PI)) - HALF_PI; // 85.0511287798 in radians export const MIN_PHI = -MAX_PHI; diff --git a/packages/math/src/geo.ts b/packages/math/src/geo.ts index 27197a4..a6c0ead 100644 --- a/packages/math/src/geo.ts +++ b/packages/math/src/geo.ts @@ -11,7 +11,7 @@ import { Vec2 } from './vector'; * @param dLat degrees latitude * @returns meters * @example - * geoLatToMeters(1); // returns ≈111319 + * geoLatToMeters(1); // returns ≈110946 */ export function geoLatToMeters(dLat: number): number { return dLat * ((TAU * POLAR_RADIUS) / 360); @@ -23,7 +23,7 @@ export function geoLatToMeters(dLat: number): number { * @param atLat latitude * @returns meters * @example - * geoLonToMeters(1, 0); // returns ≈110946 at equator + * geoLonToMeters(1, 0); // returns ≈111319 at equator */ export function geoLonToMeters(dLon: number, atLat: number): number { return Math.abs(atLat) >= 90 @@ -36,7 +36,7 @@ export function geoLonToMeters(dLon: number, atLat: number): number { * @param m meters * @returns degrees latitude * @example - * geoMetersToLat(111319); // returns ≈1° + * geoMetersToLat(110946); // returns ≈1° */ export function geoMetersToLat(m: number): number { return m / ((TAU * POLAR_RADIUS) / 360); @@ -88,15 +88,35 @@ export function geoOffsetToMeters(offset: Vec2, tileSize: number = 256): Vec2 { /** Equirectangular approximation of spherical distances on Earth + * @remarks The scalar overload can avoid temporary tuple allocations in hot loops. * @param a * @param b * @returns distance in meters * @example * geoSphericalDistance([0, 0], [1, 0]); // returns ≈110946 meters */ -export function geoSphericalDistance(a: Vec2, b: Vec2): number { - const x: number = geoLonToMeters(a[0] - b[0], (a[1] + b[1]) / 2); - const y: number = geoLatToMeters(a[1] - b[1]); +export function geoSphericalDistance(a: Vec2, b: Vec2): number; +export function geoSphericalDistance(lon1: number, lat1: number, lon2: number, lat2: number): number; +export function geoSphericalDistance(a: Vec2 | number, b: Vec2 | number, c?: number, d?: number): number { + let lon1: number; + let lat1: number; + let lon2: number; + let lat2: number; + + if (typeof a === 'number') { + lon1 = a; + lat1 = b as number; + lon2 = c as number; + lat2 = d as number; + } else { + lon1 = a[0]; + lat1 = a[1]; + lon2 = (b as Vec2)[0]; + lat2 = (b as Vec2)[1]; + } + + const x: number = geoLonToMeters(lon1 - lon2, (lat1 + lat2) / 2); + const y: number = geoLatToMeters(lat1 - lat2); return Math.sqrt(x * x + y * y); } @@ -123,7 +143,7 @@ export function geoScaleToZoom(k: number, tileSize: number = 256): number { * geoZoomToScale(17); // returns ≈5340353.7154 */ export function geoZoomToScale(z: number, tileSize: number = 256): number { - return (tileSize * Math.pow(2, z)) / TAU; + return (tileSize * (2 ** z)) / TAU; } @@ -147,7 +167,8 @@ export function geoSphericalClosestPoint(points: Vec2[], a: Vec2): Closest | nul let idx: number | undefined; for (let i = 0; i < points.length; i++) { - const distance = geoSphericalDistance(points[i], a); + const p = points[i]; + const distance = geoSphericalDistance(p[0], p[1], a[0], a[1]); if (distance < minDistance) { minDistance = distance; idx = i; diff --git a/packages/math/src/geom.ts b/packages/math/src/geom.ts index 75ba17a..c24a62f 100644 --- a/packages/math/src/geom.ts +++ b/packages/math/src/geom.ts @@ -5,7 +5,7 @@ import { polygonHull as d3_polygonHull, polygonCentroid as d3_polygonCentroid } from 'd3-polygon'; import { Extent } from './Extent'; -import { Vec2, vecCross, vecInterp, vecLength, vecRotate, vecSubtract } from './vector'; +import { Vec2, vecLength } from './vector'; /** Test whether two given coordinates describe the same edge @@ -33,8 +33,19 @@ export function geomEdgeEqual(a: Vec2, b: Vec2): boolean { */ export function geomRotatePoints(points: Vec2[], angle: number, around: Vec2): Vec2[] { const result: Vec2[] = new Array(points.length); // prealloc + const sin = Math.sin(angle); + const cos = Math.cos(angle); + const aroundX = around[0]; + const aroundY = around[1]; + for (let i = 0; i < points.length; i++) { - result[i] = vecRotate(points[i], angle, around); + const point = points[i]; + const radialX = point[0] - aroundX; + const radialY = point[1] - aroundY; + result[i] = [ + radialX * cos - radialY * sin + aroundX, + radialX * sin + radialY * cos + aroundY + ]; } return result; } @@ -56,24 +67,51 @@ export function geomRotatePoints(points: Vec2[], angle: number, around: Vec2): V * const b = [[5, 5], [5, -5]]; * geomLineIntersection(a, b); // returns [5, 0] */ -export function geomLineIntersection(a: Vec2[], b: Vec2[]): Vec2 | null { - if (a.length !== 2 || b.length !== 2) return null; - - const p: Vec2 = [a[0][0], a[0][1]]; - const p2: Vec2 = [a[1][0], a[1][1]]; - const q: Vec2 = [b[0][0], b[0][1]]; - const q2: Vec2 = [b[1][0], b[1][1]]; - const r: Vec2 = vecSubtract(p2, p); - const s: Vec2 = vecSubtract(q2, q); - const uNumerator = vecCross(vecSubtract(q, p), r); - const denominator = vecCross(r, s); +export function geomLineIntersection(a: Vec2[], b: Vec2[]): Vec2 | null; +export function geomLineIntersection(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2): Vec2 | null; +export function geomLineIntersection( + aOrA0: Vec2[] | Vec2, + bOrA1: Vec2[] | Vec2, + cOrB0?: Vec2, + dOrB1?: Vec2 +): Vec2 | null { + let a0: Vec2; + let a1: Vec2; + let b0: Vec2; + let b1: Vec2; + + if (cOrB0 !== undefined && dOrB1 !== undefined) { + a0 = aOrA0 as Vec2; + a1 = bOrA1 as Vec2; + b0 = cOrB0; + b1 = dOrB1; + } else { + const a = aOrA0 as Vec2[]; + const b = bOrA1 as Vec2[]; + if (a.length !== 2 || b.length !== 2) return null; + a0 = a[0]; + a1 = a[1]; + b0 = b[0]; + b1 = b[1]; + } + + const rx = a1[0] - a0[0]; + const ry = a1[1] - a0[1]; + const sx = b1[0] - b0[0]; + const sy = b1[1] - b0[1]; + const qpx = b0[0] - a0[0]; + const qpy = b0[1] - a0[1]; + + // 2D cross products: r×s determines parallelism; (q-p)×r and (q-p)×s solve segment parameters. + const uNumerator = qpx * ry - qpy * rx; + const denominator = rx * sy - ry * sx; if (uNumerator && denominator) { const u = uNumerator / denominator; - const t = vecCross(vecSubtract(q, p), s) / denominator; + const t = (qpx * sy - qpy * sx) / denominator; // t,u are intersection factors along segments a and b if (t >= 0 && t <= 1 && u >= 0 && u <= 1) { - return vecInterp(p, p2, t); + return [a0[0] + t * rx, a0[1] + t * ry]; } } @@ -98,10 +136,10 @@ export function geomLineIntersection(a: Vec2[], b: Vec2[]): Vec2 | null { export function geomPathIntersections(path1: Vec2[], path2: Vec2[]): Vec2[] { const intersections: Vec2[] = []; for (let i = 0; i < path1.length - 1; i++) { + const a0 = path1[i]; + const a1 = path1[i + 1]; for (let j = 0; j < path2.length - 1; j++) { - const a: Vec2[] = [path1[i], path1[i + 1]]; - const b: Vec2[] = [path2[j], path2[j + 1]]; - const hit: Vec2 | null = geomLineIntersection(a, b); + const hit = geomLineIntersection(a0, a1, path2[j], path2[j + 1]); if (hit) { intersections.push(hit); } @@ -127,11 +165,10 @@ export function geomPathIntersections(path1: Vec2[], path2: Vec2[]): Vec2[] { */ export function geomPathHasIntersections(path1: Vec2[], path2: Vec2[]): boolean { for (let i = 0; i < path1.length - 1; i++) { + const a0 = path1[i]; + const a1 = path1[i + 1]; for (let j = 0; j < path2.length - 1; j++) { - const a: Vec2[] = [path1[i], path1[i + 1]]; - const b: Vec2[] = [path2[j], path2[j + 1]]; - const hit: Vec2 | null = geomLineIntersection(a, b); - if (hit) { + if (geomLineIntersection(a0, a1, path2[j], path2[j + 1])) { return true; } } diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index 4559b26..763aec3 100644 --- a/packages/math/src/vector.ts +++ b/packages/math/src/vector.ts @@ -5,6 +5,7 @@ export type Vec2 = [number, number]; export type Vec3 = [number, number, number]; +export type Vec4 = [number, number, number, number]; /** Test whether two given vectors are equal * @param a @@ -69,10 +70,13 @@ export function vecScale(a: Vec2, n: number): Vec2 { * vecRotate([1, 1], Math.PI, [0, 0]); // returns [-1, -1] */ export function vecRotate(a: Vec2, angle: number, around: Vec2): Vec2 { - const radial = vecSubtract(a, around); + const radialX = a[0] - around[0]; + const radialY = a[1] - around[1]; + const sin = Math.sin(angle); + const cos = Math.cos(angle); return [ - radial[0] * Math.cos(angle) - radial[1] * Math.sin(angle) + around[0], - radial[0] * Math.sin(angle) + radial[1] * Math.cos(angle) + around[1] + radialX * cos - radialY * sin + around[0], + radialX * sin + radialY * cos + around[1] ]; } @@ -161,6 +165,7 @@ export function vecLength(a: Vec2, b: Vec2 = [0, 0]): number { /** Returns the length of a vector squared * This is the same as `vecLength` but without the `Math.sqrt` step, * thus avoiding an unnecessary calculation. + * @remarks The scalar overload can avoid temporary tuple allocations in tight loops. * @param a * @param b If not passed, defaults to [0,0]. * @returns vector length squared @@ -168,9 +173,23 @@ export function vecLength(a: Vec2, b: Vec2 = [0, 0]): number { * vecLengthSquare([0, 0], [4, 3]); // returns 25 * vecLengthSquare([4, 3]); // returns 25 */ -export function vecLengthSquare(a: Vec2, b: Vec2 = [0, 0]): number { - const x = a[0] - b[0]; - const y = a[1] - b[1]; +export function vecLengthSquare(a: Vec2, b?: Vec2): number; +export function vecLengthSquare(ax: number, ay: number, bx?: number, by?: number): number; +export function vecLengthSquare(a: Vec2 | number, b?: Vec2 | number, c?: number, d?: number): number { + let x: number; + let y: number; + + if (typeof a === 'number') { + const bx = c ?? 0; + const by = d ?? 0; + x = a - bx; + y = (b as number) - by; + } else { + const bVec = (Array.isArray(b) ? b : undefined); + x = a[0] - (bVec ? bVec[0] : 0); + y = a[1] - (bVec ? bVec[1] : 0); + } + return x * x + y * y; } @@ -205,6 +224,7 @@ export function vecAngle(a: Vec2, b: Vec2): number { /** Returns the dot product of two vectors + * @remarks The scalar overload can avoid temporary tuple allocations in hot code paths. * @param a * @param b * @param origin If not passed, defaults to [0,0] @@ -212,11 +232,16 @@ export function vecAngle(a: Vec2, b: Vec2): number { * @example * vecDot([2, 0], [2, 0]); // returns 4 */ -export function vecDot(a: Vec2, b: Vec2, origin?: Vec2): number { - origin = origin || [0, 0]; - const p: Vec2 = vecSubtract(a, origin); - const q: Vec2 = vecSubtract(b, origin); - return p[0] * q[0] + p[1] * q[1]; +export function vecDot(a: Vec2, b: Vec2, origin?: Vec2): number; +export function vecDot(ax: number, ay: number, bx: number, by: number): number; +export function vecDot(a: Vec2 | number, b: Vec2 | number, c?: Vec2 | number, d?: number): number { + if (typeof a === 'number') { + return a * (c as number) + (b as number) * (d as number); + } + + const bVec = b as Vec2; + const origin = (Array.isArray(c) ? c : [0, 0]) as Vec2; + return (a[0] - origin[0]) * (bVec[0] - origin[0]) + (a[1] - origin[1]) * (bVec[1] - origin[1]); } @@ -237,6 +262,7 @@ export function vecNormalizedDot(a: Vec2, b: Vec2, origin?: Vec2): number { /** Returns the 2D cross product of OA and OB vectors + * @remarks The scalar overload can avoid temporary tuple allocations in hot code paths. * @param a A * @param b B * @param origin If not passed, defaults to [0,0] @@ -245,11 +271,16 @@ export function vecNormalizedDot(a: Vec2, b: Vec2, origin?: Vec2): number { * @example * vecCross([2, 0], [0, 2]); // returns 4 */ -export function vecCross(a: Vec2, b: Vec2, origin?: Vec2): number { - origin = origin || [0, 0]; - const p = vecSubtract(a, origin); - const q = vecSubtract(b, origin); - return p[0] * q[1] - p[1] * q[0]; +export function vecCross(a: Vec2, b: Vec2, origin?: Vec2): number; +export function vecCross(ax: number, ay: number, bx: number, by: number): number; +export function vecCross(a: Vec2 | number, b: Vec2 | number, c?: Vec2 | number, d?: number): number { + if (typeof a === 'number') { + return a * (d as number) - (b as number) * (c as number); + } + + const bVec = b as Vec2; + const origin = (Array.isArray(c) ? c : [0, 0]) as Vec2; + return (a[0] - origin[0]) * (bVec[1] - origin[1]) - (a[1] - origin[1]) * (bVec[0] - origin[0]); } @@ -285,24 +316,27 @@ export function vecProject(a: Vec2, points: Vec2[]): Edge | null { for (let i = 0; i < points.length - 1; i++) { const o: Vec2 = points[i]; - const s: Vec2 = vecSubtract(points[i + 1], o); - const v: Vec2 = vecSubtract(a, o); - const proj = vecDot(v, s) / vecDot(s, s); - let p: Vec2; + const segmentEnd: Vec2 = points[i + 1]; + const sx = segmentEnd[0] - o[0]; + const sy = segmentEnd[1] - o[1]; + const vx = a[0] - o[0]; + const vy = a[1] - o[1]; + const proj = vecDot(vx, vy, sx, sy) / vecDot(sx, sy, sx, sy); + let projected: Vec2; if (proj < 0) { - p = o; + projected = o; } else if (proj > 1) { - p = points[i + 1]; + projected = segmentEnd; } else { - p = [o[0] + proj * s[0], o[1] + proj * s[1]]; + projected = [o[0] + proj * sx, o[1] + proj * sy]; } - const dist = vecLength(p, a); + const dist = vecLength(projected, a); if (dist < min) { min = dist; idx = i + 1; - target = p; + target = projected; } } diff --git a/packages/math/test/Tiler.test.js b/packages/math/test/Tiler.test.js index 19bee10..0a53485 100644 --- a/packages/math/test/Tiler.test.js +++ b/packages/math/test/Tiler.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'bun:test'; import { strict as assert } from 'bun:assert'; -import { Extent, Tiler, Viewport } from '../src/math.ts'; +import { Extent, Tiler, QUARTER_PI, Viewport, WORLD_SIZE } from '../src/math.ts'; assert.closeTo = function(a, b, epsilon = 1e-9) { @@ -52,8 +52,8 @@ describe('math/tiler', () => { assert.ok(tileExtent instanceof Extent); assert.equal(tileExtent.min[0], 0); assert.equal(tileExtent.min[1], 0); - assert.equal(tileExtent.max[0], 16777216); - assert.equal(tileExtent.max[1], 16777216); + assert.equal(tileExtent.max[0], WORLD_SIZE); + assert.equal(tileExtent.max[1], WORLD_SIZE); }); it(`tiles have a wgs84Extent property (z=0, tileSize=${tileSize})`, () => { @@ -112,8 +112,8 @@ describe('math/tiler', () => { expected.forEach((xyz, i) => { const [x, y, z] = xyz; const tileExtent = tiles[i].tileExtent; - const tileScale = 16777216 / Math.pow(2, z); - assert.equal(tileExtent.min[0], x * tileScale); // 0..8388608..16777216 + const tileScale = WORLD_SIZE / (2 ** z); + assert.equal(tileExtent.min[0], x * tileScale); // 0..8388608..WORLD_SIZE assert.equal(tileExtent.min[1], y * tileScale); assert.equal(tileExtent.max[0], (x + 1) * tileScale); assert.equal(tileExtent.max[1], (y + 1) * tileScale); @@ -190,8 +190,8 @@ describe('math/tiler', () => { expected.forEach((xyz, i) => { const [x, y, z] = xyz; const tileExtent = tiles[i].tileExtent; - const tileScale = 16777216 / Math.pow(2, z); - assert.equal(tileExtent.min[0], x * tileScale); // 0..4194304..8388608..12582912..16777216 + const tileScale = WORLD_SIZE / (2 ** z); + assert.equal(tileExtent.min[0], x * tileScale); // 0..4194304..8388608..12582912..WORLD_SIZE assert.equal(tileExtent.min[1], y * tileScale); assert.equal(tileExtent.max[0], (x + 1) * tileScale); assert.equal(tileExtent.max[1], (y + 1) * tileScale); @@ -591,7 +591,7 @@ describe('math/tiler', () => { // const t = new Tiler(); const v = new Viewport(); - v.transform = { x: 64, y: 64, z: 2, r: Math.PI / 4 }; + v.transform = { x: 64, y: 64, z: 2, r: QUARTER_PI }; v.dimensions = [128, 128]; const result = t.getTiles(v); @@ -635,7 +635,7 @@ describe('math/tiler', () => { // const t = new Tiler().margin(1); const v = new Viewport(); - v.transform = { x: 64, y: 64, z: 2, r: Math.PI / 4 }; + v.transform = { x: 64, y: 64, z: 2, r: QUARTER_PI }; v.dimensions = [128, 128]; const result = t.getTiles(v); @@ -693,7 +693,7 @@ describe('math/tiler', () => { // const t = new Tiler(); const v = new Viewport(); - v.transform = { x: 128, y: 0, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: 0, z: 2, r: QUARTER_PI }; v.dimensions = [128, 128]; const result = t.getTiles(v); @@ -737,7 +737,7 @@ describe('math/tiler', () => { // const t = new Tiler().margin(1); const v = new Viewport(); - v.transform = { x: 128, y: 0, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: 0, z: 2, r: QUARTER_PI }; v.dimensions = [128, 128]; const result = t.getTiles(v); @@ -795,7 +795,7 @@ describe('math/tiler', () => { // const t = new Tiler(); const v = new Viewport(); - v.transform = { x: 128, y: -128, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: -128, z: 2, r: QUARTER_PI }; v.dimensions = [5, 5]; // very small const result = t.getTiles(v); @@ -838,7 +838,7 @@ describe('math/tiler', () => { // const t = new Tiler().margin(1); const v = new Viewport(); - v.transform = { x: 128, y: -128, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: -128, z: 2, r: QUARTER_PI }; v.dimensions = [5, 5]; // very small const result = t.getTiles(v); @@ -894,7 +894,7 @@ describe('math/tiler', () => { // const t = new Tiler(); const v = new Viewport(); - v.transform = { x: 128, y: 128, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: 128, z: 2, r: QUARTER_PI }; v.dimensions = [512, 512]; const result = t.getTiles(v); @@ -939,7 +939,7 @@ describe('math/tiler', () => { // const t = new Tiler().margin(1); const v = new Viewport(); - v.transform = { x: 128, y: 128, z: 2, r: Math.PI / 4 }; + v.transform = { x: 128, y: 128, z: 2, r: QUARTER_PI }; v.dimensions = [512, 512]; const result = t.getTiles(v); @@ -997,7 +997,7 @@ describe('math/tiler', () => { // const t = new Tiler(); const v = new Viewport(); - v.transform = { x: 0, y: 0, z: 2, r: Math.PI / 4 }; + v.transform = { x: 0, y: 0, z: 2, r: QUARTER_PI }; v.dimensions = [256, 256]; const result = t.getTiles(v); @@ -1042,7 +1042,7 @@ describe('math/tiler', () => { // const t = new Tiler().margin(1); const v = new Viewport(); - v.transform = { x: 0, y: 0, z: 2, r: Math.PI / 4 }; + v.transform = { x: 0, y: 0, z: 2, r: QUARTER_PI }; v.dimensions = [256, 256]; const result = t.getTiles(v); diff --git a/packages/math/test/Transform.test.js b/packages/math/test/Transform.test.js index 372f895..26ec51b 100644 --- a/packages/math/test/Transform.test.js +++ b/packages/math/test/Transform.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'bun:test'; import { strict as assert } from 'bun:assert'; -import { Transform } from '../src/math.ts'; +import { ANGLE_EPSILON, HALF_PI, TAU, Transform } from '../src/math.ts'; describe('math/Transform', () => { @@ -16,12 +16,12 @@ describe('math/Transform', () => { }); it('creates a Transform from another Transform-like', () => { - const t = new Transform({ x: '20', y: '30', z: '2', r: Math.PI / 2 }); + const t = new Transform({ x: '20', y: '30', z: '2', r: HALF_PI }); assert.ok(t instanceof Transform); assert.equal(t.x, 20); assert.equal(t.y, 30); assert.equal(t.z, 2); - assert.equal(t.r, Math.PI / 2); + assert.equal(t.r, HALF_PI); assert.equal(t.v, 2); }); @@ -42,6 +42,16 @@ describe('math/Transform', () => { assert.ok(t2 instanceof Transform); assert.equal(t2.r, Math.PI); }); + + it('snaps near-zero wrapped rotations to 0', () => { + const t1 = new Transform({ r: ANGLE_EPSILON / 2 }); + assert.ok(t1 instanceof Transform); + assert.equal(t1.r, 0); + + const t2 = new Transform({ r: TAU - (ANGLE_EPSILON / 2) }); + assert.ok(t2 instanceof Transform); + assert.equal(t2.r, 0); + }); }); describe('#translation', () => { @@ -125,6 +135,14 @@ describe('math/Transform', () => { assert.equal(t.rotation, Math.PI); }); + it('snaps near-zero wrapped rotations to 0', () => { + const t = new Transform(); + t.rotation = ANGLE_EPSILON / 2; + assert.equal(t.rotation, 0); + t.rotation = TAU - (ANGLE_EPSILON / 2); + assert.equal(t.rotation, 0); + }); + it('increments version only on actual change', () => { const t = new Transform(); const v0 = t.v; @@ -145,6 +163,17 @@ describe('math/Transform', () => { assert.deepEqual(t.rotation, 0); assert.equal(t.v, v0); // no increment }); + + it('does not increment version for tiny near-zero rotations from 0', () => { + const t = new Transform(); + const v0 = t.v; + t.rotation = ANGLE_EPSILON / 2; + assert.equal(t.rotation, 0); + assert.equal(t.v, v0); + t.rotation = TAU - (ANGLE_EPSILON / 2); + assert.equal(t.rotation, 0); + assert.equal(t.v, v0); + }); }); describe('#props', () => { @@ -153,11 +182,11 @@ describe('math/Transform', () => { assert.deepEqual(t.props, { x: 0, y: 0, z: 1, r: 0 }); assert.equal(t.v, 1); - t.props = { x: '20', y: '30', z: '2', r: Math.PI / 2 }; + t.props = { x: '20', y: '30', z: '2', r: HALF_PI }; assert.equal(t.x, 20); assert.equal(t.y, 30); assert.equal(t.z, 2); - assert.equal(t.r, Math.PI / 2); + assert.equal(t.r, HALF_PI); assert.equal(t.v, 2); }); @@ -177,12 +206,20 @@ describe('math/Transform', () => { assert.equal(t.rotation, Math.PI); }); + it('snaps near-zero wrapped rotations to 0', () => { + const t = new Transform(); + t.props = { r: ANGLE_EPSILON / 2 }; + assert.equal(t.rotation, 0); + t.props = { r: TAU - (ANGLE_EPSILON / 2) }; + assert.equal(t.rotation, 0); + }); + it('increments version only on actual change', () => { const t = new Transform(); const v0 = t.v; - t.props = { x: '20', y: '30', z: '2', r: Math.PI / 2 }; + t.props = { x: '20', y: '30', z: '2', r: HALF_PI }; assert.equal(t.v, v0 + 1); // increment once - t.props = { x: '20', y: '30', z: '2', r: Math.PI / 2 }; + t.props = { x: '20', y: '30', z: '2', r: HALF_PI }; assert.equal(t.v, v0 + 1); // no increment }); diff --git a/packages/math/test/Viewport.test.js b/packages/math/test/Viewport.test.js index fb5c0fe..1700a88 100644 --- a/packages/math/test/Viewport.test.js +++ b/packages/math/test/Viewport.test.js @@ -1,6 +1,6 @@ import { describe, it } from 'bun:test'; import { strict as assert } from 'bun:assert'; -import { DEG2RAD, Extent, Transform, Viewport } from '../src/math.ts'; +import { DEG2RAD, HALF_PI, Extent, Transform, Viewport, WORLD_SIZE } from '../src/math.ts'; assert.closeTo = function(a, b, epsilon = 1e-9) { @@ -22,14 +22,14 @@ describe('math/viewport', () => { }); it('creates a Viewport with a Transform-like param', () => { - const view = new Viewport({ x: '20', y: '30', z: 2, r: Math.PI / 2 }); + const view = new Viewport({ x: '20', y: '30', z: 2, r: HALF_PI }); const tform = view.transform; assert.ok(view instanceof Viewport); assert.ok(tform instanceof Transform); assert.equal(tform.x, 20); assert.equal(tform.y, 30); assert.equal(tform.z, 2); - assert.equal(tform.r, Math.PI / 2); + assert.equal(tform.r, HALF_PI); assert.equal(tform.v, 2); }); @@ -42,7 +42,7 @@ describe('math/viewport', () => { describe('#project / #unproject', () => { for (const z of [0, 1, 2]) { - const w = Math.pow(2, z) * 128; // half the tile size + const w = (2 ** z) * 128; // half the tile size const h = w; it(`Projects [0°, 0°] -> [0, 0] (at z${z})`, () => { @@ -94,7 +94,7 @@ describe('math/viewport', () => { }); it(`Ignores rotation when projecting, when 'includeRotation' is 'false' (at z${z})`, () => { - const view = new Viewport({ z: z, r: Math.PI / 2 }); // quarter turn clockwise + const view = new Viewport({ z: z, r: HALF_PI }); // quarter turn clockwise const point = view.project([180, 0]); assert.ok(point instanceof Array); assert.closeTo(point[0], w); @@ -102,7 +102,7 @@ describe('math/viewport', () => { }); it(`Applies rotation when projecting, when 'includeRotation' is 'true' (at z${z})`, () => { - const view = new Viewport({ z: z, r: Math.PI / 2 }); // quarter turn clockwise + const view = new Viewport({ z: z, r: HALF_PI }); // quarter turn clockwise const point = view.project([180, 0], true); assert.ok(point instanceof Array); assert.closeTo(point[0], 0); @@ -158,7 +158,7 @@ describe('math/viewport', () => { }); it(`Ignores rotation when unprojecting, when 'includeRotation' is 'false' (at z${z})`, () => { - const view = new Viewport({ z: z, r: Math.PI / 2 }); // quarter turn clockwise + const view = new Viewport({ z: z, r: HALF_PI }); // quarter turn clockwise const point = view.unproject([0, h]); assert.ok(point instanceof Array); assert.closeTo(point[0], 0); @@ -166,7 +166,7 @@ describe('math/viewport', () => { }); it(`Applies rotation when unprojecting, when 'includeRotation' is 'true' (at z${z})`, () => { - const view = new Viewport({ z: z, r: Math.PI / 2 }); // quarter turn clockwise + const view = new Viewport({ z: z, r: HALF_PI }); // quarter turn clockwise const point = view.unproject([0, h], true); assert.ok(point instanceof Array); assert.closeTo(point[0], 180); @@ -193,25 +193,25 @@ describe('math/viewport', () => { assert.closeTo(point[1], 0, 1e-3); // small residual at polar boundary }); - it(`Projects [-180°, -85.0511287798°] -> [0, 16777216]`, () => { + it(`Projects [-180°, -85.0511287798°] -> [0, ${WORLD_SIZE}]`, () => { const point = view.wgs84ToWorld([-180, -85.0511287798]); assert.ok(point instanceof Array); assert.closeTo(point[0], 0); - assert.closeTo(point[1], 16777216, 1e-3); // small residual at polar boundary + assert.closeTo(point[1], WORLD_SIZE, 1e-3); // small residual at polar boundary }); - it(`Projects [180°, 85.0511287798°] -> [16777216, 0]`, () => { + it(`Projects [180°, 85.0511287798°] -> [${WORLD_SIZE}, 0]`, () => { const point = view.wgs84ToWorld([180, 85.0511287798]); assert.ok(point instanceof Array); - assert.closeTo(point[0], 16777216); + assert.closeTo(point[0], WORLD_SIZE); assert.closeTo(point[1], 0, 1e-3); // small residual at polar boundary }); - it(`Projects [180°, -85.0511287798°] -> [16777216, 16777216]`, () => { - const point = view.wgs84ToWorld([180, 85.0511287798]); + it(`Projects [180°, -85.0511287798°] -> [${WORLD_SIZE}, ${WORLD_SIZE}]`, () => { + const point = view.wgs84ToWorld([180, -85.0511287798]); assert.ok(point instanceof Array); - assert.closeTo(point[0], 16777216); - assert.closeTo(point[1], 0, 1e-3); // small residual at polar boundary + assert.closeTo(point[0], WORLD_SIZE); + assert.closeTo(point[1], WORLD_SIZE, 1e-3); // small residual at polar boundary }); it(`Projects out of bounds [-270°, 95°] -> [-4194304, 0]`, () => { @@ -221,11 +221,11 @@ describe('math/viewport', () => { assert.closeTo(point[1], 0, 1e-3); // clamp y (small residual at polar boundary) }); - it(`Projects out of bounds [270°, -95°] -> [20971520, 16777216]`, () => { + it(`Projects out of bounds [270°, -95°] -> [20971520, ${WORLD_SIZE}]`, () => { const point = view.wgs84ToWorld([270, -95]); assert.ok(point instanceof Array); assert.closeTo(point[0], 20971520); // wrap x - assert.closeTo(point[1], 16777216, 1e-3); // clamp y (small residual at polar boundary) + assert.closeTo(point[1], WORLD_SIZE, 1e-3); // clamp y (small residual at polar boundary) }); }); @@ -247,22 +247,22 @@ describe('math/viewport', () => { assert.closeTo(point[1], 85.0511287798); }); - it(`Unprojects [0, 16777216] -> [-180°, -85.0511287798°]`, () => { - const point = view.worldToWgs84([0, 16777216]); + it(`Unprojects [0, ${WORLD_SIZE}] -> [-180°, -85.0511287798°]`, () => { + const point = view.worldToWgs84([0, WORLD_SIZE]); assert.ok(point instanceof Array); assert.closeTo(point[0], -180); assert.closeTo(point[1], -85.0511287798); }); - it(`Unprojects [16777216, 0] -> [180°, 85.0511287798°]`, () => { - const point = view.worldToWgs84([16777216, 0]); + it(`Unprojects [${WORLD_SIZE}, 0] -> [180°, 85.0511287798°]`, () => { + const point = view.worldToWgs84([WORLD_SIZE, 0]); assert.ok(point instanceof Array); assert.closeTo(point[0], 180); assert.closeTo(point[1], 85.0511287798); }); - it(`Unprojects [16777216, 16777216] -> [180°, -85.0511287798°]`, () => { - const point = view.worldToWgs84([16777216, 16777216]); + it(`Unprojects [${WORLD_SIZE}, ${WORLD_SIZE}] -> [180°, -85.0511287798°]`, () => { + const point = view.worldToWgs84([WORLD_SIZE, WORLD_SIZE]); assert.ok(point instanceof Array); assert.closeTo(point[0], 180); assert.closeTo(point[1], -85.0511287798); @@ -288,33 +288,33 @@ describe('math/viewport', () => { describe('#transform', () => { it('sets/gets transform', () => { const view = new Viewport(); - view.transform = { x: '20', y: '30', z: 2, r: Math.PI / 2 }; + view.transform = { x: '20', y: '30', z: 2, r: HALF_PI }; const tform = view.transform; assert.ok(tform instanceof Transform); assert.equal(tform.x, 20); assert.equal(tform.y, 30); assert.equal(tform.z, 2); - assert.equal(tform.r, Math.PI / 2); + assert.equal(tform.r, HALF_PI); }); it('ignores missing / invalid properties', () => { - const view = new Viewport({ x: 20, y: 30, z: 2, r: Math.PI / 2 }); + const view = new Viewport({ x: 20, y: 30, z: 2, r: HALF_PI }); view.transform = { x: 10, fake: 10 }; const tform = view.transform; assert.ok(tform instanceof Transform); assert.equal(tform.x, 10); assert.equal(tform.y, 30); assert.equal(tform.z, 2); - assert.equal(tform.r, Math.PI / 2); + assert.equal(tform.r, HALF_PI); assert.equal(tform.fake, undefined); }); it('increments version only on actual change', () => { const view = new Viewport({ x: 20, y: 30, z: 2 }); const v0 = view.v; - view.transform = { r: Math.PI / 2 }; + view.transform = { r: HALF_PI }; assert.equal(view.v, v0 + 1); // increment once - view.transform = { r: Math.PI / 2 }; + view.transform = { r: HALF_PI }; assert.equal(view.v, v0 + 1); // no increment }); }); @@ -436,21 +436,21 @@ describe('math/viewport', () => { // const tests = { '0': [400, 300], - '45': [500, 500], + '45': [495, 495], '90': [300, 400], - '135': [500, 500], + '135': [495, 495], '180': [400, 300], - '225': [500, 500], + '225': [495, 495], '270': [300, 400], - '315': [500, 500], + '315': [495, 495], '360': [400, 300], - '-315': [500, 500], + '-315': [495, 495], '-270': [300, 400], - '-225': [500, 500], + '-225': [495, 495], '-180': [400, 300], - '-135': [500, 500], + '-135': [495, 495], '-90': [300, 400], - '-45': [500, 500] + '-45': [495, 495] }; for (const [key, expected] of Object.entries(tests)) { @@ -462,8 +462,8 @@ describe('math/viewport', () => { const result = view.visibleDimensions(); assert.ok(result instanceof Array); - assert.equal(result[0][0], expected[0][0]); - assert.equal(result[1][1], expected[0][1]); + assert.equal(result[0], expected[0]); + assert.equal(result[1], expected[1]); }); } }); diff --git a/packages/math/test/geo.test.js b/packages/math/test/geo.test.js index 7b684a6..2841960 100644 --- a/packages/math/test/geo.test.js +++ b/packages/math/test/geo.test.js @@ -148,6 +148,10 @@ describe('math/geo', () => { const b = [0, 61]; assert.closeTo(math.geoSphericalDistance(a, b), 110946, 1); }); + it('supports scalar overload for hot paths', () => { + assert.closeTo(math.geoSphericalDistance(0, 0, 1, 0), 111319, 1); + assert.equal(math.geoSphericalDistance(0, 0, 0, 0), 0); + }); }); describe('geoZoomToScale', () => { diff --git a/packages/math/test/vector.test.js b/packages/math/test/vector.test.js index ff64d8a..81b6d2a 100644 --- a/packages/math/test/vector.test.js +++ b/packages/math/test/vector.test.js @@ -136,6 +136,10 @@ describe('math/vector', () => { it('defaults second argument to [0,0]', () => { assert.equal(math.vecLengthSquare([4, 3]), 25); }); + it('supports scalar overload for hot paths', () => { + assert.equal(math.vecLengthSquare(0, 0, 4, 3), 25); + assert.equal(math.vecLengthSquare(4, 3), 25); + }); }); describe('vecNormalize', () => { @@ -169,6 +173,10 @@ describe('math/vector', () => { const b = [2, 0]; assert.equal(math.vecDot(a, b), 4); }); + it('supports scalar overload for hot paths', () => { + assert.equal(math.vecDot(2, 0, 2, 0), 4); + assert.equal(math.vecDot(1, 0, 0, 1), 0); + }); }); describe('vecNormalizedDot', () => { @@ -205,6 +213,10 @@ describe('math/vector', () => { const b = [2, 0]; assert.equal(math.vecCross(a, b), -0); }); + it('supports scalar overload for hot paths', () => { + assert.equal(math.vecCross(2, 0, 0, 2), 4); + assert.equal(math.vecCross(2, 0, 0, -2), -4); + }); }); describe('vecProject', () => {