Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Created August 11, 2024 12:48
Show Gist options
  • Save nandordudas/c2e88d42c74661b92b6f432066db352e to your computer and use it in GitHub Desktop.
Save nandordudas/c2e88d42c74661b92b6f432066db352e to your computer and use it in GitHub Desktop.
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
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)
}
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
}
}
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