Add rectangle resize methods, use in DragAndScale (#1057)

This commit is contained in:
filtered
2025-05-17 03:22:51 +10:00
committed by GitHub
parent 06413df706
commit d1ec780dbd
3 changed files with 210 additions and 139 deletions

View File

@@ -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 {

View File

@@ -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]

View File

@@ -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 = {