import { Matrix } from './Matrix'; import { Point } from './types'; interface DOMRectTransformInit { height?: number; width?: number; x?: number; y?: number; rotation?: number; transformOrigin?: Point; rotateOrigin?: Point; } /** * Represents a rectangle with position, size, and rotation, * capable of transforming points between local and parent coordinate spaces. * * **Coordinate System:** * - The origin `(0, 0)` is at the **top-left corner**. * - Positive `x` values extend **to the right**. * - Positive `y` values extend **downward**. * - Rotation is **clockwise**, in **radians**, around the rectangle's **center**. */ export class DOMRectTransform implements DOMRect { // Private properties for position, size, rotation, and origins #x: number; // X-coordinate of the top-left corner #y: number; // Y-coordinate of the top-left corner #width: number; // Width of the rectangle #height: number; // Height of the rectangle #rotation: number; // Rotation angle in radians, clockwise // New properties for transform origin and rotation origin #transformOrigin: Point; // Origin for transformations #rotateOrigin: Point; // Origin for rotation // Internal transformation matrices #transformMatrix: Matrix; // Transforms from local to parent space #inverseMatrix: Matrix; // Transforms from parent to local space /** * Constructs a new `TransformDOMRect`. * @param init - Optional initial values. */ constructor(init: DOMRectTransformInit = {}) { this.#x = init.x ?? 0; this.#y = init.y ?? 0; this.#width = init.width ?? 0; this.#height = init.height ?? 0; this.#rotation = init.rotation ?? 0; // Initialize origins with relative values (0.5, 0.5 is center) this.#transformOrigin = init.transformOrigin ?? { x: 0.5, y: 0.5 }; this.#rotateOrigin = init.rotateOrigin ?? { x: 0.5, y: 0.5 }; // Initialize transformation matrices this.#transformMatrix = Matrix.Identity(); this.#inverseMatrix = Matrix.Identity(); this.#updateMatrices(); } // Getters and setters for properties /** Gets or sets the **x-coordinate** of the top-left corner. */ get x(): number { return this.#x; } set x(value: number) { this.#x = value; this.#updateMatrices(); } /** Gets or sets the **y-coordinate** of the top-left corner. */ get y(): number { return this.#y; } set y(value: number) { this.#y = value; this.#updateMatrices(); } /** Gets or sets the **width** of the rectangle. */ get width(): number { return this.#width; } set width(value: number) { this.#width = value; this.#updateMatrices(); } /** Gets or sets the **height** of the rectangle. */ get height(): number { return this.#height; } set height(value: number) { this.#height = value; this.#updateMatrices(); } /** Gets or sets the **rotation angle** in radians, **clockwise**. */ get rotation(): number { return this.#rotation; } set rotation(value: number) { this.#rotation = value; this.#updateMatrices(); } /** Gets or sets the **transform origin** as relative values (0 to 1). */ get transformOrigin(): Point { return this.#transformOrigin; } set transformOrigin(value: Point) { this.#transformOrigin = value; this.#updateMatrices(); } /** Gets or sets the **rotation origin** as relative values (0 to 1). */ get rotateOrigin(): Point { return this.#rotateOrigin; } set rotateOrigin(value: Point) { this.#rotateOrigin = value; this.#updateMatrices(); } // DOMRect read-only properties /** The **left** coordinate of the rectangle (same as `x`). */ get left(): number { return this.x; } /** The **top** coordinate of the rectangle (same as `y`). */ get top(): number { return this.y; } /** The **right** coordinate of the rectangle (`x + width`). */ get right(): number { return this.x + this.width; } /** The **bottom** coordinate of the rectangle (`y + height`). */ get bottom(): number { return this.y + this.height; } /** * Updates the transformation matrices based on the current position, * size, rotation, and origins of the rectangle. * * The transformation sequence is: * 1. **Translate** to the global position. * 2. **Translate** to the transform origin. * 3. **Rotate** around the rotation origin. * 4. **Translate** back from the transform origin. */ #updateMatrices() { // Reset the transformMatrix to identity this.#transformMatrix.identity(); // Get absolute positions for origins const transformOrigin = this.#getAbsoluteTransformOrigin(); const rotateOrigin = this.#getAbsoluteRotateOrigin(); // Apply transformations this.#transformMatrix // Step 1: Translate to global position .translate(this.#x, this.#y) // Step 2: Translate to the transform origin .translate(transformOrigin.x, transformOrigin.y) // Step 3: Rotate around the rotation origin .translate(rotateOrigin.x - transformOrigin.x, rotateOrigin.y - transformOrigin.y) .rotate(this.#rotation) .translate(-(rotateOrigin.x - transformOrigin.x), -(rotateOrigin.y - transformOrigin.y)) // Step 4: Translate back from the transform origin .translate(-transformOrigin.x, -transformOrigin.y); // Update inverseMatrix as the inverse of transformMatrix this.#inverseMatrix = this.#transformMatrix.clone().invert(); } // Convert relative origins to absolute points #getAbsoluteTransformOrigin(): Point { return { x: this.#width * this.#transformOrigin.x, y: this.#height * this.#transformOrigin.y, }; } #getAbsoluteRotateOrigin(): Point { return { x: this.#width * this.#rotateOrigin.x, y: this.#height * this.#rotateOrigin.y, }; } // Accessors for the transformation matrices get transformMatrix(): Matrix { return this.#transformMatrix; } get inverseMatrix(): Matrix { return this.#inverseMatrix; } /** * Converts a point from **parent space** to **local space**. * @param point - The point in parent coordinate space. * @returns The point in local coordinate space. */ toLocalSpace(point: Point): Point { return this.#inverseMatrix.applyToPoint(point); } /** * Converts a point from **local space** to **parent space**. * @param point - The point in local coordinate space. * @returns The point in parent coordinate space. */ toParentSpace(point: Point): Point { return this.#transformMatrix.applyToPoint(point); } // Local space corners /** * Gets the **top-left** corner of the rectangle in **local space** (before transformation). */ get topLeft(): Point { return { x: 0, y: 0 }; } /** * Gets the **top-right** corner of the rectangle in **local space** (before transformation). */ get topRight(): Point { return { x: this.width, y: 0 }; } /** * Gets the **bottom-right** corner of the rectangle in **local space** (before transformation). */ get bottomRight(): Point { return { x: this.width, y: this.height }; } /** * Gets the **bottom-left** corner of the rectangle in **local space** (before transformation). */ get bottomLeft(): Point { return { x: 0, y: this.height }; } /** * Gets the **center point** of the rectangle in **parent space**. */ get center(): Point { return { x: this.x + this.width / 2, y: this.y + this.height / 2, }; } /** * Gets the four corner vertices of the rectangle in **local space**. * @returns An array of points in the order: top-left, top-right, bottom-right, bottom-left. */ vertices(): Point[] { return [this.topLeft, this.topRight, this.bottomRight, this.bottomLeft]; } /** * Generates a CSS transform string representing the rectangle's transformation. * @returns A string suitable for use in CSS `transform` properties. */ toCssString(): string { return this.transformMatrix.toCssString(); } /** * Converts the rectangle's properties to a JSON serializable object. * @returns An object containing the rectangle's `x`, `y`, `width`, `height`, and `rotation`. */ toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height, rotation: this.rotation, }; } // TODO: these setters work but surely there's a better way /** * Sets the **top-left** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly, * and keeps the **bottom-right corner** fixed in the **parent space**. * @param point - The new top-left corner point in local coordinate space. */ set topLeft(point: Point) { // Compute the parent-space position of the bottom-right corner before resizing const bottomRightBefore = this.toParentSpace(this.bottomRight); // Update x, y, width, and height const deltaWidth = this.#width - point.x; const deltaHeight = this.#height - point.y; this.#x += point.x; this.#y += point.y; this.#width = deltaWidth; this.#height = deltaHeight; // Update transformation matrices after changing size and position this.#updateMatrices(); // Compute the parent-space position of the bottom-right corner after resizing const bottomRightAfter = this.toParentSpace(this.bottomRight); // Compute the difference in position const deltaX = bottomRightAfter.x - bottomRightBefore.x; const deltaY = bottomRightAfter.y - bottomRightBefore.y; // Adjust x and y to compensate for the movement this.#x -= deltaX; this.#y -= deltaY; // Update matrices again after adjusting position this.#updateMatrices(); } /** * Sets the **top-right** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly, * and keeps the **bottom-left corner** fixed in the **parent space**. * @param point - The new top-right corner point in local coordinate space. */ set topRight(point: Point) { // Compute the parent-space position of the bottom-left corner before resizing const bottomLeftBefore = this.toParentSpace(this.bottomLeft); // Update y, width, and height const deltaWidth = point.x; const deltaHeight = this.#height - point.y; this.#y += point.y; this.#width = deltaWidth; this.#height = deltaHeight; // Update transformation matrices after changing size and position this.#updateMatrices(); // Compute the parent-space position of the bottom-left corner after resizing const bottomLeftAfter = this.toParentSpace(this.bottomLeft); // Compute the difference in position const deltaX = bottomLeftAfter.x - bottomLeftBefore.x; const deltaY = bottomLeftAfter.y - bottomLeftBefore.y; // Adjust x and y to compensate for the movement this.#x -= deltaX; this.#y -= deltaY; // Update matrices again after adjusting position this.#updateMatrices(); } /** * Sets the **bottom-right** corner of the rectangle in **local space**, adjusting the width and height accordingly, * and keeps the **top-left corner** fixed in the **parent space**. * @param point - The new bottom-right corner point in local coordinate space. */ set bottomRight(point: Point) { // Compute the parent-space position of the top-left corner before resizing const topLeftBefore = this.toParentSpace(this.topLeft); // Update width and height this.#width = point.x; this.#height = point.y; // Update transformation matrices after changing size this.#updateMatrices(); // Compute the parent-space position of the top-left corner after resizing const topLeftAfter = this.toParentSpace(this.topLeft); // Compute the difference in position const deltaX = topLeftAfter.x - topLeftBefore.x; const deltaY = topLeftAfter.y - topLeftBefore.y; // Adjust x and y to compensate for the movement this.#x -= deltaX; this.#y -= deltaY; // Update matrices again after adjusting position this.#updateMatrices(); } /** * Sets the **bottom-left** corner of the rectangle in **local space**, adjusting the position, width, and height accordingly, * and keeps the **top-right corner** fixed in the **parent space**. * @param point - The new bottom-left corner point in local coordinate space. */ set bottomLeft(point: Point) { // Compute the parent-space position of the top-right corner before resizing const topRightBefore = this.toParentSpace(this.topRight); // Update x, width, and height const deltaWidth = this.#width - point.x; const deltaHeight = point.y; this.#x += point.x; this.#width = deltaWidth; this.#height = deltaHeight; // Update transformation matrices after changing size and position this.#updateMatrices(); // Compute the parent-space position of the top-right corner after resizing const topRightAfter = this.toParentSpace(this.topRight); // Compute the difference in position const deltaX = topRightAfter.x - topRightBefore.x; const deltaY = topRightAfter.y - topRightBefore.y; // Adjust x and y to compensate for the movement this.#x -= deltaX; this.#y -= deltaY; // Update matrices again after adjusting position this.#updateMatrices(); } /** * Computes the **axis-aligned bounding box** of the transformed rectangle in **parent space**. * @returns An object representing the bounding rectangle with properties: `x`, `y`, `width`, `height`. */ getBounds(): DOMRectInit { // Transform all vertices to parent space const transformedVertices = this.vertices().map((v) => this.toParentSpace(v)); // Find min and max coordinates const xs = transformedVertices.map((v) => v.x); const ys = transformedVertices.map((v) => v.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY, }; } } /** * A **read-only** version of `TransformDOMRect` that prevents modification of position, * size, and rotation properties. */ export class DOMRectTransformReadonly extends DOMRectTransform { constructor(init: DOMRectTransformInit = {}) { super(init); } // Explicit overrides for all getters from parent class override get x(): number { return super.x; } override get y(): number { return super.y; } override get width(): number { return super.width; } override get height(): number { return super.height; } override get rotation(): number { return super.rotation; } override get left(): number { return super.left; } override get top(): number { return super.top; } override get right(): number { return super.right; } override get bottom(): number { return super.bottom; } override get transformMatrix(): Matrix { return super.transformMatrix; } override get inverseMatrix(): Matrix { return super.inverseMatrix; } override get topLeft(): Point { return super.topLeft; } override get topRight(): Point { return super.topRight; } override get bottomRight(): Point { return super.bottomRight; } override get bottomLeft(): Point { return super.bottomLeft; } override get center(): Point { return super.center; } // Add no-op setters override set x(value: number) { // no-op } override set y(value: number) { // no-op } override set width(value: number) { // no-op } override set height(value: number) { // no-op } override set rotation(value: number) { // no-op } }