diff --git a/src/DragAndScale.ts b/src/DragAndScale.ts index 608de53aa..8a5698860 100644 --- a/src/DragAndScale.ts +++ b/src/DragAndScale.ts @@ -92,7 +92,7 @@ export class DragAndScale { } computeVisibleArea(viewport: Rect | undefined): void { - const { scale, offset } = this + const { scale, offset, visible_area } = this if (this.#stateHasChanged()) { this.onChanged?.(scale, offset) @@ -100,11 +100,10 @@ export class DragAndScale { } if (!this.element) { - this.visible_area[0] = this.visible_area[1] = this.visible_area[2] = this.visible_area[3] = 0 + visible_area[0] = visible_area[1] = visible_area[2] = visible_area[3] = 0 return } - let width = this.element.width - let height = this.element.height + let { width, height } = this.element let startx = -offset[0] let starty = -offset[1] if (viewport) { @@ -115,10 +114,9 @@ export class DragAndScale { } const endx = startx + width / scale const endy = starty + height / scale - this.visible_area[0] = startx - this.visible_area[1] = starty - this.visible_area[2] = endx - startx - this.visible_area[3] = endy - starty + visible_area[0] = startx + visible_area[1] = starty + visible_area.resizeBottomRight(endx, endy) } toCanvasContext(ctx: CanvasRenderingContext2D): void { diff --git a/src/infrastructure/Rectangle.ts b/src/infrastructure/Rectangle.ts index b200dd0b6..9a40b57d3 100644 --- a/src/infrastructure/Rectangle.ts +++ b/src/infrastructure/Rectangle.ts @@ -162,10 +162,10 @@ export class Rectangle extends Float64Array { */ containsXy(x: number, y: number): boolean { const { x: left, y: top, width, height } = this - return left <= x && - top <= y && - left + width >= x && - top + height >= y + return x >= left && + x < left + width && + y >= top && + y < top + height } /** @@ -244,6 +244,37 @@ export class Rectangle extends Float64Array { return [this[0] - x, this[1] - y] } + /** Resizes the rectangle without moving it, setting its top-left corner to [{@link x}, {@link y}]. */ + resizeTopLeft(x1: number, y1: number) { + this[2] += this[0] - x1 + this[3] += this[1] - y1 + + this[0] = x1 + this[1] = y1 + } + + /** Resizes the rectangle without moving it, setting its bottom-left corner to [{@link x}, {@link y}]. */ + resizeBottomLeft(x1: number, y2: number) { + this[2] += this[0] - x1 + this[3] = y2 - this[1] + + this[0] = x1 + } + + /** Resizes the rectangle without moving it, setting its top-right corner to [{@link x}, {@link y}]. */ + resizeTopRight(x2: number, y1: number) { + this[2] = x2 - this[0] + this[3] += this[1] - y1 + + this[1] = y1 + } + + /** Resizes the rectangle without moving it, setting its bottom-right corner to [{@link x}, {@link y}]. */ + resizeBottomRight(x2: number, y2: number) { + this[2] = x2 - this[0] + this[3] = y2 - this[1] + } + /** Sets the width without moving the right edge (changes position) */ setWidthRightAnchored(width: number) { const currentWidth = this[2] diff --git a/test/infrastructure/Rectangle.test.ts b/test/infrastructure/Rectangle.test.ts index 9c549cc2d..839b301ec 100644 --- a/test/infrastructure/Rectangle.test.ts +++ b/test/infrastructure/Rectangle.test.ts @@ -13,7 +13,7 @@ const test = baseTest.extend<{ rect: Rectangle }>({ }) describe("Rectangle", () => { - describe("constructor", () => { + describe("constructor and basic properties", () => { test("should create a default rectangle", ({ rect }) => { expect(rect.x).toBe(0) expect(rect.y).toBe(0) @@ -21,6 +21,7 @@ describe("Rectangle", () => { expect(rect.height).toBe(0) expect(rect.length).toBe(4) }) + test("should create a rectangle with specified values", () => { const rect = new Rectangle(1, 2, 3, 4) expect(rect.x).toBe(1) @@ -28,9 +29,18 @@ describe("Rectangle", () => { expect(rect.width).toBe(3) expect(rect.height).toBe(4) }) + + test("should update the rectangle values", ({ rect }) => { + const newValues: [number, number, number, number] = [1, 2, 3, 4] + rect.updateTo(newValues) + expect(rect.x).toBe(1) + expect(rect.y).toBe(2) + expect(rect.width).toBe(3) + expect(rect.height).toBe(4) + }) }) - describe("subarray", () => { + describe("array operations", () => { test("should return a Float64Array representing the subarray", () => { const rect = new Rectangle(10, 20, 30, 40) const sub = rect.subarray(1, 3) @@ -39,6 +49,7 @@ describe("Rectangle", () => { expect(sub[0]).toBe(20) // y expect(sub[1]).toBe(30) // width }) + test("should return a Float64Array for the entire array if no args", () => { const rect = new Rectangle(10, 20, 30, 40) const sub = rect.subarray() @@ -49,9 +60,22 @@ describe("Rectangle", () => { expect(sub[2]).toBe(30) expect(sub[3]).toBe(40) }) + + test("should return an array with [x, y, width, height]", () => { + const rect = new Rectangle(1, 2, 3, 4) + const arr = rect.toArray() + expect(arr).toEqual([1, 2, 3, 4]) + expect(Array.isArray(arr)).toBe(true) + expect(arr).not.toBeInstanceOf(Float64Array) + + const exported = rect.export() + expect(exported).toEqual([1, 2, 3, 4]) + expect(Array.isArray(exported)).toBe(true) + expect(exported).not.toBeInstanceOf(Float64Array) + }) }) - describe("pos", () => { + describe("position and size properties", () => { test("should get the position", ({ rect }) => { rect.x = 10 rect.y = 20 @@ -60,12 +84,14 @@ describe("Rectangle", () => { expect(pos[1]).toBe(20) expect(pos.length).toBe(2) }) + test("should set the position", ({ rect }) => { const newPos: Point = [5, 15] rect.pos = newPos expect(rect.x).toBe(5) expect(rect.y).toBe(15) }) + test("should update the rectangle when the returned pos object is modified", ({ rect }) => { rect.x = 1 rect.y = 2 @@ -75,9 +101,7 @@ describe("Rectangle", () => { expect(rect.x).toBe(100) expect(rect.y).toBe(200) }) - }) - describe("size", () => { test("should get the size", ({ rect }) => { rect.width = 30 rect.height = 40 @@ -86,12 +110,14 @@ describe("Rectangle", () => { expect(size[1]).toBe(40) expect(size.length).toBe(2) }) + test("should set the size", ({ rect }) => { const newSize: Size = [35, 45] rect.size = newSize expect(rect.width).toBe(35) expect(rect.height).toBe(45) }) + test("should update the rectangle when the returned size object is modified", ({ rect }) => { rect.width = 3 rect.height = 4 @@ -103,79 +129,73 @@ describe("Rectangle", () => { }) }) - // #region Property accessors - describe("x", () => { + describe("edge properties", () => { test("should get x", ({ rect }) => { rect[0] = 5 expect(rect.x).toBe(5) }) + test("should set x", ({ rect }) => { rect.x = 10 expect(rect[0]).toBe(10) }) - }) - describe("y", () => { test("should get y", ({ rect }) => { rect[1] = 6 expect(rect.y).toBe(6) }) + test("should set y", ({ rect }) => { rect.y = 11 expect(rect[1]).toBe(11) }) - }) - describe("width", () => { test("should get width", ({ rect }) => { rect[2] = 7 expect(rect.width).toBe(7) }) + test("should set width", ({ rect }) => { rect.width = 12 expect(rect[2]).toBe(12) }) - }) - describe("height", () => { test("should get height", ({ rect }) => { rect[3] = 8 expect(rect.height).toBe(8) }) + test("should set height", ({ rect }) => { rect.height = 13 expect(rect[3]).toBe(13) }) - }) - describe("left", () => { test("should get left", ({ rect }) => { rect[0] = 1 expect(rect.left).toBe(1) }) + test("should set left", ({ rect }) => { rect.left = 2 expect(rect[0]).toBe(2) }) - }) - describe("top", () => { test("should get top", ({ rect }) => { rect[1] = 3 expect(rect.top).toBe(3) }) + test("should set top", ({ rect }) => { rect.top = 4 expect(rect[1]).toBe(4) }) - }) - describe("right", () => { test("should get right", ({ rect }) => { rect[0] = 1 rect[2] = 10 expect(rect.right).toBe(11) }) + test("should set right", ({ rect }) => { rect.x = 1 rect.width = 10 // right is 11 @@ -183,14 +203,13 @@ describe("Rectangle", () => { expect(rect.x).toBe(10) // x = right - width = 20 - 10 expect(rect.width).toBe(10) }) - }) - describe("bottom", () => { test("should get bottom", ({ rect }) => { rect[1] = 2 rect[3] = 20 expect(rect.bottom).toBe(22) }) + test("should set bottom", ({ rect }) => { rect.y = 2 rect.height = 20 // bottom is 22 @@ -198,9 +217,7 @@ describe("Rectangle", () => { expect(rect.y).toBe(10) // y = bottom - height = 30 - 20 expect(rect.height).toBe(20) }) - }) - describe("centreX", () => { test("should get centreX", () => { const rect = new Rectangle(0, 0, 10, 0) expect(rect.centreX).toBe(5) @@ -209,9 +226,7 @@ describe("Rectangle", () => { rect.width = 20 expect(rect.centreX).toBe(15) // 5 + (20 * 0.5) }) - }) - describe("centreY", () => { test("should get centreY", () => { const rect = new Rectangle(0, 0, 0, 10) expect(rect.centreY).toBe(5) @@ -221,38 +236,81 @@ describe("Rectangle", () => { expect(rect.centreY).toBe(15) // 5 + (20 * 0.5) }) }) - // #endregion Property accessors - describe("updateTo", () => { - test("should update the rectangle values", ({ rect }) => { - const newValues: [number, number, number, number] = [1, 2, 3, 4] - rect.updateTo(newValues) - expect(rect.x).toBe(1) - expect(rect.y).toBe(2) - expect(rect.width).toBe(3) - expect(rect.height).toBe(4) + describe("geometric operations", () => { + test("should return the centre point", () => { + const rect = new Rectangle(10, 20, 30, 40) // centreX = 10 + 15 = 25, centreY = 20 + 20 = 40 + const centre = rect.getCentre() + expect(centre[0]).toBe(25) + expect(centre[1]).toBe(40) + expect(centre).not.toBe(rect.pos) // Should be a new Point + }) + + test("should return the area", () => { + expect(new Rectangle(0, 0, 5, 10).getArea()).toBe(50) + expect(new Rectangle(1, 1, 0, 10).getArea()).toBe(0) + }) + + test("should return the perimeter", () => { + expect(new Rectangle(0, 0, 5, 10).getPerimeter()).toBe(30) // 2 * (5+10) + expect(new Rectangle(0, 0, 0, 0).getPerimeter()).toBe(0) + }) + + test("should return the top-left point", () => { + const rect = new Rectangle(1, 2, 3, 4) + const tl = rect.getTopLeft() + expect(tl[0]).toBe(1) + expect(tl[1]).toBe(2) + expect(tl).not.toBe(rect.pos) + }) + + test("should return the bottom-right point", () => { + const rect = new Rectangle(1, 2, 10, 20) // right=11, bottom=22 + const br = rect.getBottomRight() + expect(br[0]).toBe(11) + expect(br[1]).toBe(22) + }) + + test("should return the size", () => { + const rect = new Rectangle(1, 2, 30, 40) + const s = rect.getSize() + expect(s[0]).toBe(30) + expect(s[1]).toBe(40) + expect(s).not.toBe(rect.size) + }) + + test("should return the offset from top-left to the point", () => { + const rect = new Rectangle(10, 20, 5, 5) + const offset = rect.getOffsetTo([12, 23]) + expect(offset[0]).toBe(2) // 12 - 10 + expect(offset[1]).toBe(3) // 23 - 20 + }) + + test("should return the offset from the point to the top-left", () => { + const rect = new Rectangle(10, 20, 5, 5) + const offset = rect.getOffsetFrom([12, 23]) + expect(offset[0]).toBe(-2) // 10 - 12 + expect(offset[1]).toBe(-3) // 20 - 23 }) }) - describe("containsXy", () => { + describe("containment and overlap", () => { const rect = new Rectangle(10, 10, 20, 20) // x: 10, y: 10, right: 30, bottom: 30 + test.each([ [10, 10, true], // top-left corner - [30, 30, true], // bottom-right corner + [29, 29, true], // bottom-right corner [15, 15, true], // inside [5, 15, false], // outside left - [35, 15, false], // outside right + [30, 15, false], // outside right [15, 5, false], // outside top - [15, 35, false], // outside bottom - [10, 30, true], // on bottom edge - [30, 10, true], // on right edge - ])("should return %s when checking if (%s, %s) is inside", (x, y, expected) => { + [15, 30, false], // outside bottom + [10, 29, true], // on bottom edge + [29, 10, true], // on right edge + ])("when checking if (%s, %s) is inside, should return %s", (x, y, expected) => { expect(rect.containsXy(x, y)).toBe(expected) }) - }) - describe("containsPoint", () => { - const rect = new Rectangle(0, 0, 10, 10) // x:0, y:0, r:10, b:10 test.each([ [[0, 0] as Point, true], [[10, 10] as Point, true], @@ -262,12 +320,10 @@ describe("Rectangle", () => { [[5, -1] as Point, false], [[5, 11] as Point, false], ])("should return %s for point %j", (point: Point, expected: boolean) => { + rect.updateTo([0, 0, 10, 10]) expect(rect.containsPoint(point)).toBe(expected) }) - }) - describe("containsRect", () => { - const outer = new Rectangle(0, 0, 100, 100) test.each([ // Completely inside [new Rectangle(10, 10, 10, 10), true], @@ -286,7 +342,9 @@ describe("Rectangle", () => { // Same size [new Rectangle(0, 0, 100, 100), true], ])("should return %s when checking if %s is inside outer rect", (inner: Rectangle, expectedOrOuter: boolean | Rectangle, expectedIfThreeArgs?: boolean) => { - let testOuter = outer + let testOuter = rect + rect.updateTo([0, 0, 100, 100]) + let testExpected = expectedOrOuter as boolean if (typeof expectedOrOuter !== "boolean") { testOuter = expectedOrOuter as Rectangle @@ -294,10 +352,7 @@ describe("Rectangle", () => { } expect(testOuter.containsRect(inner)).toBe(testExpected) }) - }) - describe("overlaps", () => { - const rect1 = new Rectangle(10, 10, 20, 20) // 10,10 to 30,30 test.each([ // Completely overlapping [new Rectangle(15, 15, 10, 10), true], // r2 inside r1 @@ -319,84 +374,87 @@ describe("Rectangle", () => { // rect1 inside rect2 [new Rectangle(0, 0, 100, 100), true], ])("should return %s for overlap with %s", (rect2, expected) => { - expect(rect1.overlaps(rect2)).toBe(expected) + const rect = new Rectangle(10, 10, 20, 20) // 10,10 to 30,30 + + expect(rect.overlaps(rect2)).toBe(expected) // Overlap should be commutative - expect(rect2.overlaps(rect1)).toBe(expected) + expect(rect2.overlaps(rect)).toBe(expected) }) }) - describe("getCentre", () => { - test("should return the centre point", () => { - const rect = new Rectangle(10, 20, 30, 40) // centreX = 10 + 15 = 25, centreY = 20 + 20 = 40 - const centre = rect.getCentre() - expect(centre[0]).toBe(25) - expect(centre[1]).toBe(40) - expect(centre).not.toBe(rect.pos) // Should be a new Point + describe("resize operations", () => { + test("should resize from top-left corner while maintaining bottom-right", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) // x: 10, y: 10, width: 20, height: 20 + rect.resizeTopLeft(5, 5) + expect(rect.x).toBe(5) + expect(rect.y).toBe(5) + expect(rect.width).toBe(25) // 20 + (10 - 5) + expect(rect.height).toBe(25) // 20 + (10 - 5) }) - }) - describe("getArea", () => { - test("should return the area", () => { - expect(new Rectangle(0, 0, 5, 10).getArea()).toBe(50) - expect(new Rectangle(1, 1, 0, 10).getArea()).toBe(0) + test("should handle negative coordinates for top-left resize", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeTopLeft(-5, -5) + expect(rect.x).toBe(-5) + expect(rect.y).toBe(-5) + expect(rect.width).toBe(35) // 20 + (10 - (-5)) + expect(rect.height).toBe(35) // 20 + (10 - (-5)) }) - }) - describe("getPerimeter", () => { - test("should return the perimeter", () => { - expect(new Rectangle(0, 0, 5, 10).getPerimeter()).toBe(30) // 2 * (5+10) - expect(new Rectangle(0, 0, 0, 0).getPerimeter()).toBe(0) + test("should resize from bottom-left corner while maintaining top-right", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeBottomLeft(5, 35) + expect(rect.x).toBe(5) + expect(rect.y).toBe(10) + expect(rect.width).toBe(25) // 20 + (10 - 5) + expect(rect.height).toBe(25) // 35 - 10 }) - }) - describe("getTopLeft", () => { - test("should return the top-left point", () => { - const rect = new Rectangle(1, 2, 3, 4) - const tl = rect.getTopLeft() - expect(tl[0]).toBe(1) - expect(tl[1]).toBe(2) - expect(tl).not.toBe(rect.pos) + test("should handle negative coordinates for bottom-left resize", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeBottomLeft(-5, 35) + expect(rect.x).toBe(-5) + expect(rect.y).toBe(10) + expect(rect.width).toBe(35) // 20 + (10 - (-5)) + expect(rect.height).toBe(25) // 35 - 10 }) - }) - describe("getBottomRight", () => { - test("should return the bottom-right point", () => { - const rect = new Rectangle(1, 2, 10, 20) // right=11, bottom=22 - const br = rect.getBottomRight() - expect(br[0]).toBe(11) - expect(br[1]).toBe(22) + test("should resize from top-right corner while maintaining bottom-left", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeTopRight(35, 5) + expect(rect.x).toBe(10) + expect(rect.y).toBe(5) + expect(rect.width).toBe(25) // 35 - 10 + expect(rect.height).toBe(25) // 20 + (10 - 5) }) - }) - describe("getSize", () => { - test("should return the size", () => { - const rect = new Rectangle(1, 2, 30, 40) - const s = rect.getSize() - expect(s[0]).toBe(30) - expect(s[1]).toBe(40) - expect(s).not.toBe(rect.size) + test("should handle negative coordinates for top-right resize", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeTopRight(35, -5) + expect(rect.x).toBe(10) + expect(rect.y).toBe(-5) + expect(rect.width).toBe(25) // 35 - 10 + expect(rect.height).toBe(35) // 20 + (10 - (-5)) }) - }) - describe("getOffsetTo", () => { - test("should return the offset from top-left to the point", () => { - const rect = new Rectangle(10, 20, 5, 5) - const offset = rect.getOffsetTo([12, 23]) - expect(offset[0]).toBe(2) // 12 - 10 - expect(offset[1]).toBe(3) // 23 - 20 + test("should resize from bottom-right corner while maintaining top-left", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeBottomRight(35, 35) + expect(rect.x).toBe(10) + expect(rect.y).toBe(10) + expect(rect.width).toBe(25) // 35 - 10 + expect(rect.height).toBe(25) // 35 - 10 }) - }) - describe("getOffsetFrom", () => { - test("should return the offset from the point to the top-left", () => { - const rect = new Rectangle(10, 20, 5, 5) - const offset = rect.getOffsetFrom([12, 23]) - expect(offset[0]).toBe(-2) // 10 - 12 - expect(offset[1]).toBe(-3) // 20 - 23 + test("should handle negative coordinates for bottom-right resize", ({ rect }) => { + rect.updateTo([10, 10, 20, 20]) + rect.resizeBottomRight(35, -5) + expect(rect.x).toBe(10) + expect(rect.y).toBe(10) + expect(rect.width).toBe(25) // 35 - 10 + expect(rect.height).toBe(-15) // -5 - 10 }) - }) - describe("setWidthRightAnchored", () => { test("should set width, anchoring the right edge", () => { const rect = new Rectangle(10, 0, 20, 0) // x:10, width:20 -> right:30 rect.setWidthRightAnchored(15) // new width 15 @@ -404,9 +462,7 @@ describe("Rectangle", () => { expect(rect.x).toBe(15) // x = oldX + (oldWidth - newWidth) = 10 + (20 - 15) = 15 expect(rect.right).toBe(30) // right should remain 30 (15+15) }) - }) - describe("setHeightBottomAnchored", () => { test("should set height, anchoring the bottom edge", () => { const rect = new Rectangle(0, 10, 0, 20) // y:10, height:20 -> bottom:30 rect.setHeightBottomAnchored(15) // new height 15 @@ -416,22 +472,7 @@ describe("Rectangle", () => { }) }) - describe("toArray / export", () => { - test("should return an array with [x, y, width, height]", () => { - const rect = new Rectangle(1, 2, 3, 4) - const arr = rect.toArray() - expect(arr).toEqual([1, 2, 3, 4]) - expect(Array.isArray(arr)).toBe(true) - expect(arr).not.toBeInstanceOf(Float64Array) - - const exported = rect.export() - expect(exported).toEqual([1, 2, 3, 4]) - expect(Array.isArray(exported)).toBe(true) - expect(exported).not.toBeInstanceOf(Float64Array) - }) - }) - - describe("_drawDebug", () => { + describe("debug drawing", () => { test("should call canvas context methods", () => { const rect = new Rectangle(10, 20, 30, 40) const mockCtx = { @@ -454,6 +495,7 @@ describe("Rectangle", () => { // For simplicity, we're assuming the implementation is correct if strokeRect was called with correct params. // A more robust test could involve spying on property assignments if vitest supports it easily. }) + test("should use default color if not provided", () => { const rect = new Rectangle(1, 2, 3, 4) const mockCtx = {