Created
August 11, 2024 12:48
-
-
Save nandordudas/c2e88d42c74661b92b6f432066db352e to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export type Constructor<T> = new (...args: any[]) => T | |
export type Brand<T, B> = T & { __brand: B } | |
export type Scalar = number | |
export type Radian = number | |
export interface Coordinates2D { | |
x: number | |
y: number | |
} | |
export type Array2D = [number, number] | |
export type ScalarOrVector2D = Scalar | Coordinates2D | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Array2D, Coordinates2D, ScalarOrVector2D } from './types' | |
export function isArray2D(coordinates: Coordinates2D | Array2D): coordinates is Array2D { | |
return Array.isArray(coordinates) && coordinates.filter(Number.isFinite).length === 2 | |
} | |
export function isNumber(value: unknown) { | |
return typeof value === 'number' | |
} | |
export function isCoordinates2D(value: unknown): value is Coordinates2D { | |
return typeof value === 'object' && value !== null && 'x' in value && 'y' in value | |
} | |
export function toCoordinates2D(scalarOrVector2D: ScalarOrVector2D): Coordinates2D { | |
if (isCoordinates2D(scalarOrVector2D)) | |
return { x: scalarOrVector2D.x, y: scalarOrVector2D.y } | |
return { x: scalarOrVector2D, y: scalarOrVector2D } | |
} | |
export function clamp(value: number, min: number, max: number) { | |
return Math.max(min, Math.min(max, value)) | |
} | |
export function randomBetween(min: number, max: number): number { | |
return Math.floor(Math.random() * (max - min + 1) + min) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Array2D, Coordinates2D, Radian, Scalar, ScalarOrVector2D } from './types' | |
import { clamp, isArray2D, isNumber, randomBetween, toCoordinates2D } from './utils' | |
export class Vector2D { | |
static readonly zero = Vector2D.create(0, 0) | |
static randomize(from: Coordinates2D, to: Coordinates2D): Vector2D { | |
return new Vector2D(randomBetween(from.x, to.x), randomBetween(from.y, to.y)) | |
} | |
static fromArray(...coordinates: Array2D): Vector2D { | |
return new Vector2D(...coordinates) | |
} | |
static fromObject({ x, y }: Coordinates2D): Vector2D { | |
return new Vector2D(x, y) | |
} | |
/** | |
* @param angleInRadians | |
* @param length Defaults to `1`. | |
*/ | |
static fromAngle(angleInRadians: Radian, length = 1): Vector2D { | |
const cosAngle = Math.cos(angleInRadians) | |
const sinAngle = Math.sin(angleInRadians) | |
return new Vector2D(cosAngle * length, sinAngle * length) | |
} | |
/** | |
* @example | |
* Vector2D.create(0, 0) | |
* Vector2D.create({ x: 0, y: 0 }) | |
* Vector2D.create([0, 0]) | |
*/ | |
static create(x: Scalar, y: Scalar): Vector2D | |
static create(coordinates: Coordinates2D): Vector2D | |
static create(coordinates: Array2D): Vector2D | |
static create(xOrCoordinates: Scalar | Coordinates2D | Array2D, y?: Scalar): Vector2D { | |
if (isNumber(xOrCoordinates)) | |
return new Vector2D(xOrCoordinates, y!) | |
if (isArray2D(xOrCoordinates)) | |
return Vector2D.fromArray(...xOrCoordinates) | |
return Vector2D.fromObject(xOrCoordinates) | |
} | |
/** | |
* @alias dot | |
*/ | |
static dotProduct(v: Vector2D, w: Vector2D): Scalar { | |
return v.x * w.x + v.y * w.y | |
} | |
/** | |
* @alias dotProduct | |
*/ | |
static dot(v: Vector2D, w: Vector2D): Scalar { | |
return Vector2D.dotProduct(v, w) | |
} | |
/** | |
* @alias cross | |
*/ | |
static crossProduct(v: Vector2D, w: Vector2D): Scalar { | |
return v.x * w.y - v.y * w.x | |
} | |
/** | |
* @alias crossProduct | |
*/ | |
static cross(v: Vector2D, w: Vector2D): Scalar { | |
return Vector2D.crossProduct(v, w) | |
} | |
/** | |
* @alias interpolate | |
*/ | |
static lerp(v: Vector2D, w: Vector2D, t: Scalar): typeof v { | |
const difference = v.subtract(w) | |
return v.add(difference.multiply(t)) | |
} | |
/** | |
* @alias lerp | |
*/ | |
static interpolate(v: Vector2D, w: Vector2D, t: Scalar): typeof v { | |
return Vector2D.lerp(v, w, t) | |
} | |
static clamp(vector: Vector2D, min: ScalarOrVector2D, max: ScalarOrVector2D): typeof vector { | |
const minV = toCoordinates2D(min) | |
const maxV = toCoordinates2D(max) | |
const clampedVector = new Vector2D( | |
clamp(vector.x, minV.x, maxV.x), | |
clamp(vector.y, minV.y, maxV.y), | |
) | |
vector.x = clampedVector.x | |
vector.y = clampedVector.y | |
return vector | |
} | |
static reflect(vector: Vector2D, normal: Vector2D): typeof vector { | |
const coefficient = Vector2D.dotProduct(vector, normal) | |
const reflection = normal.multiply(2 * coefficient) | |
return vector.subtract(reflection) | |
} | |
static project(vector: Vector2D, normal: Vector2D): typeof vector { | |
const coefficient = Vector2D.dotProduct(vector, normal) | |
const projection = normal.multiply(coefficient) | |
return projection | |
} | |
/** | |
* @param v | |
* @param w | |
* @param coordinate Axis to check. Defaults to `'both'`. | |
*/ | |
static distance(v: Vector2D, w: Vector2D, coordinate: keyof Coordinates2D | 'both' = 'both'): Scalar { | |
if (coordinate !== 'both') | |
return Math.abs(v[coordinate] - w[coordinate]) | |
return Math.hypot(v.x - w.x, v.y - w.y) | |
} | |
/** | |
* @param v | |
* @param w | |
* @param axis Whether to return angle relative to axes. Defaults to `x`. | |
* @alias slope | |
*/ | |
static angle(v: Vector2D, w: Vector2D, axis: keyof Coordinates2D = 'x'): Radian { | |
if (v.x === w.x) { | |
console.warn('Cannot calculate angle between collinear vectors') | |
return Number.POSITIVE_INFINITY | |
} | |
const angle = Math.atan2(w.y - v.y, w.x - v.x) | |
if (axis === 'x') | |
return angle | |
return angle + Math.PI / 2 | |
} | |
/** | |
* @param v | |
* @param w | |
* @param axis Whether to return angle relative to axes. Defaults to `x`. | |
* @alias angle | |
*/ | |
static slope(v: Vector2D, w: Vector2D, axis: keyof Coordinates2D = 'x'): Radian { | |
return Vector2D.angle(v, w, axis) | |
} | |
static rotate(vector: Vector2D, angleInRadians: Radian): typeof vector { | |
const cosAngle = Math.cos(angleInRadians) | |
const sinAngle = Math.sin(angleInRadians) | |
const rotatedVector = new Vector2D( | |
vector.x * cosAngle - vector.y * sinAngle, | |
vector.x * sinAngle + vector.y * cosAngle, | |
) | |
vector.x = rotatedVector.x | |
vector.y = rotatedVector.y | |
return vector | |
} | |
/** | |
* @alias midpoint | |
*/ | |
static mean(v: Vector2D, w: Vector2D): typeof v { | |
return v.add(w).divide(2) | |
} | |
/** | |
* @alias mean | |
*/ | |
static midpoint(v: Vector2D, w: Vector2D): typeof v { | |
return Vector2D.mean(v, w) | |
} | |
private constructor( | |
public x: Scalar, | |
public y: Scalar, | |
) { } | |
toString(): string { | |
return `${this.constructor.name}(${this.x}, ${this.y})` | |
} | |
toArray(): Readonly<Array2D> { | |
return Object.freeze<Array2D>([this.x, this.y]) | |
} | |
toObject(): Readonly<Coordinates2D> { | |
return Object.freeze<Coordinates2D>({ x: this.x, y: this.y }) | |
} | |
/** | |
* @alias copy | |
* @alias detach | |
*/ | |
clone(): Vector2D { | |
return new Vector2D(this.x, this.y) | |
} | |
/** | |
* @alias clone | |
* @alias detach | |
*/ | |
detach(): Vector2D { | |
return this.clone() | |
} | |
/** | |
* @alias clone | |
* @alias detach | |
*/ | |
copy(): Vector2D { | |
return this.clone() | |
} | |
/** | |
* @param axis Axis to check. Defaults to `'both'`. | |
*/ | |
isOnAxis(axis: keyof Coordinates2D | 'both' = 'both'): boolean { | |
if (!axis) | |
return this.x === 0 || this.y === 0 | |
return this[axis] === 0 | |
} | |
isEqualTo(other: Vector2D): boolean { | |
return this.x === other.x && this.y === other.y | |
} | |
isZero(): boolean { | |
return this.x === 0 && this.y === 0 | |
} | |
/** | |
* @alias move | |
* @alias translate | |
*/ | |
add(scalarOrVector2D: ScalarOrVector2D): this { | |
const other = toCoordinates2D(scalarOrVector2D) | |
this.x += other.x | |
this.y += other.y | |
return this | |
} | |
/** | |
* @alias add | |
* @alias translate | |
*/ | |
move(scalarOrVector2D: ScalarOrVector2D): this { | |
return this.add(scalarOrVector2D) | |
} | |
/** | |
* @alias add | |
* @alias translate | |
*/ | |
translate(scalarOrVector2D: ScalarOrVector2D): this { | |
return this.add(scalarOrVector2D) | |
} | |
subtract(scalarOrVector2D: ScalarOrVector2D): this { | |
const other = toCoordinates2D(scalarOrVector2D) | |
this.x -= other.x | |
this.y -= other.y | |
return this | |
} | |
/** | |
* @alias scale | |
*/ | |
multiply(scalarOrVector2D: ScalarOrVector2D): this { | |
const other = toCoordinates2D(scalarOrVector2D) | |
this.x *= other.x | |
this.y *= other.y | |
return this | |
} | |
/** | |
* @alias multiply | |
*/ | |
scale(scalarOrVector2D: ScalarOrVector2D): this { | |
return this.multiply(scalarOrVector2D) | |
} | |
/** | |
* @alias div | |
*/ | |
divide(scalarOrVector2D: ScalarOrVector2D): this { | |
const other = toCoordinates2D(scalarOrVector2D) | |
if (other.x === 0 || other.y === 0) { | |
console.warn('Division by a collinear vector is not allowed') | |
this.x = 0 | |
this.y = 0 | |
return this | |
} | |
this.x /= other.x | |
this.y /= other.y | |
return this | |
} | |
/** | |
* @alias divide | |
*/ | |
div(scalarOrVector2D: ScalarOrVector2D): this { | |
return this.divide(scalarOrVector2D) | |
} | |
magnitudeSquared(): Scalar { | |
return this.x * this.x + this.y * this.y | |
} | |
/** | |
* @alias length | |
*/ | |
magnitude(): Scalar { | |
return Math.hypot(this.x, this.y) | |
} | |
/** | |
* @alias magnitude | |
*/ | |
length(): Scalar { | |
return this.magnitude() | |
} | |
/** | |
* @alias negate | |
* @param axis Axis to check. Defaults to `'both'`. | |
*/ | |
invert(axis: keyof Coordinates2D | 'both' = 'both'): this { | |
if (axis === 'both') { | |
this.x *= -1 | |
this.y *= -1 | |
return this | |
} | |
if (axis === 'x') | |
this.x *= -1 | |
if (axis === 'y') | |
this.y *= -1 | |
return this | |
} | |
/** | |
* @alias invert | |
* @param axis Axis to check. Defaults to `'both'`. | |
*/ | |
negate(axis: keyof Coordinates2D | 'both' = 'both'): this { | |
return this.invert(axis) | |
} | |
swap(): this { | |
[this.x, this.y] = [this.y, this.x] | |
return this | |
} | |
/** | |
* @alias perpendicular | |
* @param isClockwise Whether to return the clockwise or counter-clockwise. Defaults to `true`. | |
*/ | |
normal(isClockwise = true): this { | |
this.x = isClockwise ? -this.y : this.y | |
this.y = isClockwise ? this.x : -this.x | |
return this | |
} | |
/** | |
* @alias normal | |
* @param isClockwise Whether to return the clockwise or counter-clockwise. Defaults to `true`. | |
*/ | |
perpendicular(isClockwise = true): this { | |
return this.normal(isClockwise) | |
} | |
/** | |
* @alias unit | |
*/ | |
normalize(): this { | |
const magnitude = this.magnitude() | |
if (magnitude === 0) { | |
console.warn('Cannot normalize zero vector') | |
return this | |
} | |
return this.divide(this.magnitude()) | |
} | |
/** | |
* @alias normalize | |
*/ | |
unit(): this { | |
return this.normalize() | |
} | |
/** | |
* @alias dot | |
*/ | |
dotProductOf(other: Vector2D): Scalar { | |
return Vector2D.dotProduct(this, other) | |
} | |
/** | |
* @alias dotProductOf | |
*/ | |
dot(other: Vector2D): Scalar { | |
return this.dotProductOf(other) | |
} | |
/** | |
* @alias cross | |
*/ | |
crossProductOf(other: Vector2D): Scalar { | |
return Vector2D.crossProduct(this, other) | |
} | |
/** | |
* @alias crossProductOf | |
*/ | |
cross(other: Vector2D): Scalar { | |
return this.crossProductOf(other) | |
} | |
/** | |
* @alias interpolate | |
*/ | |
lerp(other: Vector2D, t: Scalar): this { | |
const vector = Vector2D.lerp(this, other, t) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
/** | |
* @alias lerp | |
*/ | |
interpolate(other: Vector2D, t: Scalar): this { | |
return this.lerp(other, t) | |
} | |
clamp(min: ScalarOrVector2D, max: ScalarOrVector2D): this { | |
const vector = Vector2D.clamp(this, min, max) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
limit(min: Scalar, max: Scalar): this { | |
const magnitude = this.magnitude() | |
if (magnitude === 0) { | |
console.warn('Cannot limit a vector with zero magnitude.') | |
return this | |
} | |
const clampedMagnitude = Math.min(Math.max(magnitude, min), max) | |
return this.multiply(clampedMagnitude / magnitude) | |
} | |
reflect(normal: Vector2D): this { | |
const vector = Vector2D.reflect(this, normal) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
/** | |
* @param other | |
* @param coordinate Axis to check. Defaults to `'both'`. | |
*/ | |
distanceTo(other: Vector2D, coordinate: keyof Coordinates2D | 'both' = 'both'): Scalar { | |
if (coordinate !== 'both') | |
return Math.abs(this[coordinate] - other[coordinate]) | |
return Vector2D.distance(this, other) | |
} | |
angleTo(other: Vector2D): Radian { | |
return Vector2D.angle(this, other) | |
} | |
rotate(angleInRadians: Radian): this { | |
const vector = Vector2D.rotate(this, angleInRadians) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
randomize(): this { | |
const vector = Vector2D.randomize(this, this) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
/** | |
* @alias midpoint | |
*/ | |
mean(other: Vector2D): this { | |
const vector = Vector2D.mean(this, other) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
/** | |
* @alias mean | |
*/ | |
midpoint(other: Vector2D): this { | |
return this.mean(other) | |
} | |
project(other: Vector2D): this { | |
const vector = Vector2D.project(this, other) | |
this.x = vector.x | |
this.y = vector.y | |
return this | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Array2D } from './types' | |
import { Vector2D } from './vector-2d' | |
interface GetItemClosestToParams<T> { | |
referenceVector: Vector2D | |
items: T[] | |
getCoordinates: (item: T) => Array2D | |
} | |
interface ClosestToParams<T extends readonly Vector2D[]> { | |
referenceVector: Vector2D | |
vectors: T | |
} | |
export function closestTo<const T extends readonly Vector2D[], R = T extends { length: 0 } ? null : Vector2D>({ | |
referenceVector, | |
vectors, | |
}: ClosestToParams<T>): R { | |
if (vectors.length === 0) | |
return null as R | |
let [closestVector, ...restVectors] = vectors | |
let minDistance = Vector2D.distance(referenceVector, closestVector) | |
for (const vector of restVectors) { | |
const distance = Vector2D.distance(referenceVector, vector) | |
if (distance > minDistance) | |
continue | |
minDistance = distance | |
closestVector = vector | |
} | |
return closestVector as R | |
} | |
export function getItemClosestTo<T>({ | |
referenceVector, | |
items, | |
getCoordinates, | |
}: GetItemClosestToParams<T>): T | null { | |
if (items.length === 0) | |
return null | |
const vectors = items.map(item => Vector2D.create(getCoordinates(item))) | |
const closestVector = closestTo({ referenceVector, vectors }) | |
const closestObject = items.find((item) => { | |
const itemVector = Vector2D.create(getCoordinates(item)) | |
return itemVector.isEqualTo(closestVector) | |
}) | |
return closestObject ?? null | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment