Over-engineering rectangles
📅
I have been kicking around an idea for a while that would require some simple collision detection logic in JavaScript. How simple? Well, the only requirement would be that I could be able to tell when two rectangles are intersecting.
Rectangle collision detection, the simple way
Any rectangle can be described by the coordinates of its opposing corners. For example:
const rectangle1 = { minX: 10, minY: 10, maxX: 30, maxY: 30 };
const rectangle2 = { minX: 20, minY: 20, maxX: 40, maxY: 40 };Finding out if two rectangles overlap is straightforward:
/**
 * Determine if two rectangles, defined by the coordinates of their opposing 
 * corners, overlap.
 * @param  {Object} rectangle1
 * @param  {Object} rectangle2
 * @return {Boolean}
 */
function areColliding( rectangle1, rectangle2 ) {
	 return (
		    ( rectangle1.minX <= rectangle2.maxX )
		 && ( rectangle1.maxX >= rectangle2.minX )
		 && ( rectangle1.minY <= rectangle2.maxY )
		 && ( rectangle1.maxY >= rectangle2.minY )
	 );
}Great! Mission accomplished, time to wrap up.
Why would I want anything else?
There are quite a few plausible and somewhat reasonable answers to the previous question. There are more things to do with rectangles, more often than not. Also, maintaining (or even browsing) an ever-growing collection of snippets to copy-paste when required may not be the best way to manage a codebase.
The answer, in my case, was “because”. The use I intend to make of whatever comes out of this will not justify its complexity. I would not be surprised if it less performant than copying and pasting the previous logic where required. Nevertheless, structuring the logic in a conventional package is useful for me, even if it is just to provide a lexical context to the code that I may end up copying and pasting.
Enter the Box class.
What’s in the Box‽
Box is a shorter name than “Rectangle” for a class that represents just that, a rectangle as per the previous definition. Basically:
/**
 * A representation of a rectangle.
 * @class
 */
class Box {
	 /** @type {Number} */
	 minX = 0;
	 /** @type {Number} */
	 maxX = 0;
	 /** @type {Number} */
	 minY = 0;
	 /** @type {Number} */
	 maxY = 0;
	 /**
	  * Determine if two `Box` instances are colliding.
	  * @param  {Box} box1
	  * @param  {Box} box2
	  * @return {Boolean}
	  */
	 static areColliding( box1, box2 ) {
		 return (
			    ( box1.minX <= box2.maxX )
			 && ( box1.maxX >= box2.minX )
			 && ( box1.minY <= box2.maxY )
			 && ( box1.maxY >= box2.minY )
		 );
	 }
	 /**
	  * Determine if the `Box` instance is colliding with `anotherBox`.
	  * @param  {Box} anotherBox
	  * @return {Boolean}
	  */
	 isColliding( anotherBox ) { return Box.areColliding( this, anotherBox ); }
	 /**
	  * Create a `Box` rectangle, defined by the coordinates of its opposing corners.
	  * @param {Number} minX
	  * @param {Number} maxX
	  * @param {Number} minY
	  * @param {Number} maxY
	  * @example <caption>Create a 10×10 `Box` at (25,30)</caption>
	  * const box = new Box( 25, 30, 35, 40 );
	  */
	 constructor( minX, minY, maxX, maxY ) {
		 this.minX = minX;
		 this.maxX = maxX;
		 this.minY = minY;
		 this.maxY = maxY;
	 }
}There you go, all nicely OOPed, with the collision detection logic in static and dynamic form.
Great! Mission accomplished, time to wrap up.
Where am I going to use this?
Nowhere, really, in this form.
The idea I have been noodling with requires the rectangles to be painted in a CanvasRenderingContext2D. In this context, rectangles are defined by its top-left corner coordinates, x and y, and its size, width and height.
Reworking the Box class with this in mind is trivial, substituting minX with x, minY with y, maxX with x + width, and maxY with y + height.
Great! Mission accomplished, time to wrap up.
Complicating things, the OOP way
What if I decided arbitrarily that I wanted to keep addition and substraction out of the collision detection logic? Well, for starters, I would have to keep the min_ and max_ variables. I would also have to implement the logic to maintain the coherence of the different properties.
And that is precisely what I am going to do.
Inside the class Box {} definition, I establish a set of private variables.
	 /** @type {Number} */
	 #minX = 0;
	 /** @type {Number} */
	 #maxX = 0;
	 /** @type {Number} */
	 #minY = 0;
	 /** @type {Number} */
	 #maxY = 0;
	 /** @type {Number} */
	 #width = 0;
	 /** @type {Number} */
	 #height = 0;Then, I define the getters and setters for the class’ original properties:
	 /** @type {Number} */
	 get minX() { return this.#minX; }
	 set minX( value ) {
		 if ( isNaN( value ) ) { return; }
		 this.#minX = value;
		 this.#maxX = value + this.#width;
	 }
	 /** @type {Number} */
	 get maxX() { return this.#maxX; }
	 set maxX( value ) {
		 if ( isNaN( value ) ) { return; }
		 this.#maxX = value;
		 this.#minX = value - this.#width;
	 }
	 /** @type {Number} */
	 get minY() { return this.#minY; }
	 set minY( value ) {
		 if ( isNaN( value ) ) { return; }
		 this.#minY = value;
		 this.#maxY = value + this.#height;
	 }
	 /** @type {Number} */
	 get maxY() { return this.#maxY; }
	 set maxY( value ) {
		 if ( isNaN( value ) ) { return; }
		 this.#maxY = value;
		 this.#minY = value - this.#height;
	 }After that, I add do the same thing for the properties with the CanvasRenderingContext2D rectangle nomenclature:
	 /** @type {Number} */
	 get x() { return this.#minX; }
	 set x( value ) { this.minX = value; }
	 /** @type {Number} */
	 get y() { return this.#minY; }
	 set y( value ) { this.minY = value; }
	 /** @type {Number} */
	 get width() { return this.#width; }
	 set width( value ) {
		 if ( isNaN( value ) || ( value < 0 ) ) { return; }
		 this.#width = value;
		 this.#maxX = this.#minX + value; // This is a choice, not self-evident.
	 }
	 /** @type {Number} */
	 get height() { return this.#height; }
	 set height( value ) {
		 if ( isNaN( value ) || ( value < 0 )  ) { return; }
		 this.#height = value;
		 this.#maxY = this.#minY + value; // This is a choice, not self-evident.
	 }After copying the areColliding and isColliding methods, the only thing left to do is update the constructor:
	 /**
	  * Create a `Box` rectangle, defined by the coordinates of a corner and its size.
	  * @param {Number} [x=0]	   
	  * @param {Number} [y=0]
	  * @param {Number} [width=0]  Cannot be a negative number.
	  * @param {Number} [height=0] Cannot be a negative number.
	  */
	 constructor( x = 0, y = 0, width = 0, height = 0 ) {
		 this.#minX = x;
		 this.#minY = y;
		 this.width = width;   // This will also set `this.#maxX` value
		 this.height = height; // This will also set `this.#maxY` value
	 }Great! Mission accomplished, time to wrap up.
More is less
That is quite a bit more code for, basically, the same functionality. Granted, now I prevent some erroneous handling of the object’s properties (e.g., setting a minX that is more than maxX) and, for a CanvasRenderingContext2D object named context, instead of
context.fillRect( box.minX, box.minY, box.maxX - box.minX, box.maxY - box.minY );, I can declare
context.fillRect( box.x, box.y, box.width, box.height );, but there are no substantial gains to be found here. Furthermore, as I stated before, I would expect the new Box class to be less performant than the original, although I would have to test it.
In for a penny, in for a pound, so, there are quite a few ways to overload this class. For example, another property could be
	 /** 
	  * @readonly
	  * @type {Number[]} An array with the values for `x`, `y`, `width` and `height`.
	  */
	 get rect() { return [ this.#minX, this.#minY, this.#width, this.#height ]; }so, again, for a CanvasRenderingContext2D object named context:
context.fillRect( ...box.rect );It does look prettier or, at least, more compact, although I would (and did) argue against some uses of the spread operator.
Great! Mission accomplished, time to wrap up.
There and back again
Having looked ahead, I can anticipate having to convert to and from the format of the data I will be handling. Why not add that, too?
	 /** 
	  * @readonly
	  * @type {Object} An object with `x`, `y`, `width` and `height` properties.
	  */
	 get rectangle() { return { 
		 x: this.#minX, y: this.#minY, width: this.#width, height: this.#height 
	 }; }
	 
	 /**
	  * Instances a `Box` from a `rect` array.
	  * @param  {Number[]} rect 
	  *		  An array with the values for `x`, `y`, `width` and `height`.
	  * @return {(Box|Number[])?} 
	  */
	  static createFromArray( rect ) {
		 if ( !(
			    ( rect instanceof Array )
			 && ( rect.length === 4 )
			 && !isNaN( rect[ 0 ] ) && !isNaN( rect[ 1 ] )
			 && !isNaN( rect[ 2 ] ) && !isNaN( rect[ 3 ] )
			 && ( rect[ 2 ] >= 0 ) 
			 && ( rect[ 3 ] >= 0 )
		 ) ) { 
			 return rect; 
		 }
		 return ( new Box ( rect[ 0 ], rect[ 1 ], rect[ 2 ], rect[ 3 ] ) );
	 }
	 /**
	  * Instances a `Box` from a `rectangle` object.
	  * @param  {Object} rectangle 
	  *			    An object with `x`, `y`, `width` and `height` properties.
	  * @return {(Box|Object)?} 
	  */
	 static createFromObject( rectangle ) {
		 if ( !(
				    ( rectangle instanceof Object )
				 && rectangle.hasOwnProperty( "x" ) && !isNaN( rectangle.x )
				 && rectangle.hasOwnProperty( "y" ) && !isNaN( rectangle.y )
				 && rectangle.hasOwnProperty( "width" ) && !isNaN( rectangle.width )
				 && rectangle.hasOwnProperty( "height" ) && !isNaN( rectangle.height )
				 && ( rectangle.width >= 0 ) 
				 && ( rectangle.height >= 0 ) 
		 ) ) { 
			 return rectangle; 
		 }
		 return ( 
			 new Box ( rectangle.x, rectangle.y, rectangle.width, rectangle.height ) 
		 );
	 }
	 /**
	  * Instances a `Box` from a `rect` array or a `rectangle` object.
	  * @param  {Number[]|Object} source 
	  * @return {(Box|Object)?} 
	  */
	  static createFrom( source ) {
		 if ( !( source instanceof Object ) ) { return source; }
		 if ( source instanceof Array ) { return Box.createFromArray( source ); }
		 return Box.createFromObject( source );
	 }Great! Mission accomplished, time to wrap up.
To be continued
By this stage, I should have started writting tests, establishing metrics and measuring. Ideally, I would have started when I defined the areColliding method.
I am not going to do it. (Shame! Shame! Shame!)
I will be able to test its functionality and performance if (hopefully, when) I start developing my idea. In the interim, maybe I can write a bit more.