summaryrefslogtreecommitdiff
path: root/apps/web/src/math
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/math')
-rw-r--r--apps/web/src/math/Coord.ts23
-rw-r--r--apps/web/src/math/Ratio.test.ts27
-rw-r--r--apps/web/src/math/Ratio.ts105
-rw-r--r--apps/web/src/math/Rect.ts104
-rw-r--r--apps/web/src/math/index.ts3
5 files changed, 262 insertions, 0 deletions
diff --git a/apps/web/src/math/Coord.ts b/apps/web/src/math/Coord.ts
new file mode 100644
index 0000000..db7ee6d
--- /dev/null
+++ b/apps/web/src/math/Coord.ts
@@ -0,0 +1,23 @@
+/** A coord on a grid whose origin is in the top left. */
+export default class Coord {
+ private readonly _x: number;
+ private readonly _y: number;
+
+ constructor(x: number, y: number) {
+ this._x = x;
+ this._y = y;
+ }
+
+ get x(): number {
+ return this._x;
+ }
+
+ get y(): number {
+ return this._y;
+ }
+
+ /** Get the squared distance of this point from the origin. */
+ squaredDistanceFromOrigin(): number {
+ return this._x * this._x + this._y * this._y;
+ }
+}
diff --git a/apps/web/src/math/Ratio.test.ts b/apps/web/src/math/Ratio.test.ts
new file mode 100644
index 0000000..da6fef2
--- /dev/null
+++ b/apps/web/src/math/Ratio.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "vitest";
+import Ratio from "./Ratio";
+
+describe(Ratio, () => {
+ describe(Ratio.prototype.add, () => {
+ test("returns fractions in simplest form", () => {
+ const a = Ratio.fromInteger(0);
+ const b = new Ratio(1, 4);
+
+ const c = a.add(b);
+ expect(c.numerator).toBe(1);
+ expect(c.denominator).toBe(4);
+
+ const d = c.add(b);
+ expect(d.numerator).toBe(1);
+ expect(d.denominator).toBe(2);
+
+ const e = d.add(b);
+ expect(e.numerator).toBe(3);
+ expect(e.denominator).toBe(4);
+
+ const f = e.add(b);
+ expect(f.numerator).toBe(1);
+ expect(f.denominator).toBe(1);
+ });
+ });
+});
diff --git a/apps/web/src/math/Ratio.ts b/apps/web/src/math/Ratio.ts
new file mode 100644
index 0000000..e2a1fbf
--- /dev/null
+++ b/apps/web/src/math/Ratio.ts
@@ -0,0 +1,105 @@
+import { gcd } from ".";
+
+/** Serializable representation of a ratio. */
+export type RatioData = [numerator: number, denominator: number];
+
+/** Representation of a ratio for performing fractional artithmetic. */
+export default class Ratio {
+ readonly #numerator: number;
+ readonly #denominator: number;
+
+ get numerator(): number {
+ return this.#numerator;
+ }
+
+ get denominator(): number {
+ return this.#denominator;
+ }
+
+ constructor(numerator: number, denominator: number) {
+ if (!Number.isInteger(numerator) || !Number.isInteger(denominator)) {
+ throw new TypeError(
+ `Ratio must have integer parts: ${numerator} / ${denominator}`,
+ );
+ }
+
+ if (denominator === 0) {
+ throw new RangeError("Ratio demnominator cannot be zero");
+ }
+
+ const divisor = gcd(numerator, denominator);
+
+ this.#numerator = numerator / divisor;
+ this.#denominator = denominator / divisor;
+ }
+
+ multiplyRatio(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.numerator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ divideRatio(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator,
+ this.denominator * other.numerator,
+ );
+ }
+
+ add(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator + other.numerator * this.denominator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ subtract(other: Ratio): Ratio {
+ return new Ratio(
+ this.numerator * other.denominator - other.numerator * this.denominator,
+ this.denominator * other.denominator,
+ );
+ }
+
+ compare(other: Ratio): number {
+ const left = this.numerator * other.denominator;
+ const right = other.numerator * this.denominator;
+ return left < right ? -1 : left > right ? 1 : 0;
+ }
+
+ equals(other: Ratio): boolean {
+ return this.compare(other) === 0;
+ }
+
+ toNumber(): number {
+ return this.numerator / this.denominator;
+ }
+
+ toString(): string {
+ return `${this.numerator}/${this.denominator}`;
+ }
+
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
+ return `Ratio { ${this.numerator}/${this.denominator} }`;
+ }
+
+ static fromInteger(n: number): Ratio {
+ return new Ratio(n, 1);
+ }
+
+ toData(): RatioData {
+ return [this.numerator, this.denominator];
+ }
+
+ static fromData(ratio: RatioData): Ratio {
+ return new Ratio(ratio[0], ratio[1]);
+ }
+
+ static min(...ratios: Ratio[]): Ratio {
+ return ratios.reduce((a, b) => (a.compare(b) <= 0 ? a : b));
+ }
+
+ static max(...ratios: Ratio[]): Ratio {
+ return ratios.reduce((a, b) => (a.compare(b) >= 0 ? a : b));
+ }
+}
diff --git a/apps/web/src/math/Rect.ts b/apps/web/src/math/Rect.ts
new file mode 100644
index 0000000..f52a2f7
--- /dev/null
+++ b/apps/web/src/math/Rect.ts
@@ -0,0 +1,104 @@
+import Coord from "./Coord";
+
+/** A rectangle on a grid whose origin is in the top left. */
+export default class Rect {
+ private readonly _topLeft: Coord;
+ private readonly _width: number;
+ private readonly _height: number;
+
+ constructor(
+ topLeftX: number,
+ topLeftY: number,
+ width: number,
+ height: number,
+ ) {
+ this._topLeft = new Coord(topLeftX, topLeftY);
+ this._width = width;
+ this._height = height;
+ }
+
+ /** Width of this rectangle. */
+ get width(): number {
+ return this._width;
+ }
+
+ /** Height of this rectangle. */
+ get height(): number {
+ return this._height;
+ }
+
+ /** Coord of the top-left point of this rectangle. */
+ get topLeft(): Coord {
+ return this._topLeft;
+ }
+
+ /** Coord of the bottom-right point of this rectangle. */
+ get bottomRight(): Coord {
+ return new Coord(
+ this._topLeft.x + this._width,
+ this._topLeft.y + this._height,
+ );
+ }
+
+ get center(): Coord {
+ return new Coord(
+ this.topLeft.x + (this.bottomRight.x - this.topLeft.x) / 2,
+ this.topLeft.y + (this.bottomRight.y - this.topLeft.y) / 2,
+ );
+ }
+
+ /** Determine if this rectangle contains the point at `coord`. */
+ containsCoord(coord: Coord): boolean {
+ return (
+ this.topLeft.x <= coord.x &&
+ coord.x <= this.bottomRight.x &&
+ this.topLeft.y <= coord.y &&
+ coord.y <= this.bottomRight.y
+ );
+ }
+
+ verticallyContainsCoord(coord: Coord): boolean {
+ return this.topLeft.y <= coord.y && coord.y <= this.bottomRight.y;
+ }
+
+ horizontallyContainsCoord(coord: Coord): boolean {
+ return this.topLeft.x <= coord.x && coord.x <= this.bottomRight.x;
+ }
+
+ extend(other: Rect): Rect {
+ const topLeftX = Math.min(
+ this.topLeft.x,
+ this.bottomRight.x,
+ other.topLeft.x,
+ other.bottomRight.x,
+ );
+
+ const topLeftY = Math.min(
+ this.topLeft.y,
+ this.bottomRight.y,
+ other.topLeft.y,
+ other.bottomRight.y,
+ );
+
+ const bottomRightX = Math.max(
+ this.topLeft.x,
+ this.bottomRight.x,
+ other.topLeft.x,
+ other.bottomRight.x,
+ );
+
+ const bottomRightY = Math.max(
+ this.topLeft.y,
+ this.bottomRight.y,
+ other.topLeft.y,
+ other.bottomRight.y,
+ );
+
+ return new Rect(
+ topLeftX,
+ topLeftY,
+ bottomRightX - topLeftX,
+ bottomRightY - topLeftY,
+ );
+ }
+}
diff --git a/apps/web/src/math/index.ts b/apps/web/src/math/index.ts
new file mode 100644
index 0000000..70dbb67
--- /dev/null
+++ b/apps/web/src/math/index.ts
@@ -0,0 +1,3 @@
+export function gcd(a: number, b: number): number {
+ return b === 0 ? Math.abs(a) : gcd(b, a % b);
+}