Repository: silx-kit/h5web
Pull request #1359 (2023-01-27 15:02:34)

Document and test `Box` utility class


Patch:
diff --git a/apps/storybook/src/Utilities.stories.mdx b/apps/storybook/src/Utilities.stories.mdx
index 217ec995..ef22512c 100644
--- a/apps/storybook/src/Utilities.stories.mdx
+++ b/apps/storybook/src/Utilities.stories.mdx
@@ -266,3 +266,159 @@ import { findMockEntity, getMockDataArray } from '@h5web/lib';
 const entity = findMockEntity('/nD_datasets/twoD');
 const dataArray = getMockDataArray('/nD_datasets/twoD');
 ```
+
+### Box
+
+Utility class that extends Three's [Box3](https://threejs.org/docs/?q=box#api/en/math/Box3) class. Particularly useful when dealing with selection rectangles, the camera's field of view, etc.
+
+#### size
+
+Compute the width and height of the box => like [getSize](https://threejs.org/docs/?q=box#api/en/math/Box3.getSize) but much simpler to use for 2D computations.
+
+> This getter creates a new `Vector3` on every access, so avoid multiple consecutive accesses.
+
+```ts
+get size(): Size
+
+// ✔ DO
+const { size } = box;
+const ratio = size.width / size.height;
+
+// ✔ DO
+const { width, height } = box.size;
+const ratio = width / height;
+
+// ❌ DON'T
+const ratio = box.size.width / box.size.height;
+```
+
+#### center
+
+Compute the center of the box => like [getCenter](https://threejs.org/docs/?q=box#api/en/math/Box3.getCenter) but without the `target` parameter.
+
+> This getter creates a new `Vector3` on every access, so avoid multiple consecutive accesses.
+
+```ts
+get center(): Vector3
+
+// ✔ DO
+const { center } = box;
+const coords = [center.x, center.y];
+
+// ✔ DO
+const { x, y } = box.center;
+const coords = [x, y];
+
+// ❌ DON'T
+const coords = [box.center.x, box.center.y];
+```
+
+#### empty
+
+Create an empty box centered on `(0, 0, 0)` => like `new Box()`, but with `min` and `max` initialised to `(0, 0, 0)` instead of `(+∞, +∞, +∞)` and `(-∞, -∞, -∞)` respectively, which allows methods like `expandBySize` to work as expected.
+
+Optionally, center the box on a given point => like `Box.fromPoints(pt)` below, but more explicit.
+
+```ts
+static empty(center?: Vector3): Box
+
+const box1 = Box.empty(); // min: (0, 0, 0); max: (0, 0, 0)
+const box2 = Box.empty(new Vector3(10, 10)); // min: (10, 10, 0); max: (10, 10, 0)
+```
+
+#### fromPoints
+
+Initialise a box that includes all the given points.
+
+> To create a box from a single point, prefer `Box.empty(pt)`.
+
+```ts
+static fromPoints(...points: Vector3[]): Box
+
+const htmlSelection: Rect = [startPt, endPt];
+const htmlBox = Box.fromPoints(...htmlSelection);
+```
+
+#### fromSize
+
+Create a box with a given size and centered on `(0, 0, 0)`.
+
+```ts
+static fromSize(size: Size): Box
+
+const box = Box.fromSize({ width: 10, height: 20 }); // min: (-5, -10, 0); max: (5, 10, 0)
+```
+
+#### clampPoint
+
+Clamp a given point to the box.
+
+> The given point is never mutated; a new `Vector3` instance is always returned.
+
+```ts
+clampPoint(pt: Vector3): Vector3
+
+const box = Box.empty().expandByPoint(new Vector3(5, 15)); // min: (0, 0, 0); max: (5, 15, 0)
+
+const pt = new Vector3(10, 10);
+const clampedPt = box.clampPoint(pt); // (5, 10, 0)
+```
+
+#### expandBySize
+
+Expand the box by a given width and height while maintaining its current center.
+
+```ts
+expandBySize(width: number, height: number): this
+
+const box = Box.empty().expandBySize(10, 20); // min: (-5, -10, 0); max: (5, 10, 0)
+```
+
+#### expandToRatio
+
+Expand the box either horizontally or vertically, and equally on both sides, in order to reach a given ratio.
+
+```ts
+expandToRatio(ratio: number | undefined): this
+
+const box = Box.empty().expandBySize(10, 10).expandToRatio(2);
+const { size } = box; // width: 20; height: 10
+```
+
+#### hasMinSize
+
+Check whether the box has at least a given width and height.
+
+```ts
+hasMinSize(minWidth: number, minHeight = minWidth): boolean
+
+const box = Box.empty().expandBySize(10, 10);
+box.hasMinSize(5, 12); // false
+box.hasMinSize(10, 12); // true
+```
+
+#### keepWithin
+
+Shift the box so it remains within the bounds of a given box.
+If the given box is too small to fit the source box, then the source box will end up overflowing the given box equally on both sides (horizontally, vertically or both).
+
+```ts
+keepWithin(area: Box): this
+
+const boundingBox = Box.empty().expandBySize(20, 20); // min: (-10, -10, 0); max: (10, 10, 0)
+
+const box = Box.empty(new Vector3(10, 10)).expandBySize(10, 10); // min: (5, 5, 0); max: (15, 15, 0)
+const shiftedBox = box.clone().keepWithin(boundingBox); // min: (0, 0, 0); max: (10, 10, 0)
+```
+
+#### toRect
+
+Convert the box to an array of two points - i.e. `[min, max]`. Note that if the box was initialised from an array of two points,
+`toRect` may not return those same two points.
+
+```ts
+toRect(): [Vector3, Vector3]
+
+const box = Box.fromPoints(new Vector3(-10, 10), new Vector3(10, -10));
+const rect = box.toRect(); // [(-10, -10, 0), (10, 10, 0)]
+```
diff --git a/packages/lib/src/interactions/box.ts b/packages/lib/src/interactions/box.ts
index 95078efa..66597372 100644
--- a/packages/lib/src/interactions/box.ts
+++ b/packages/lib/src/interactions/box.ts
@@ -32,18 +32,26 @@ class Box extends Box3 {
   }
 
   public expandBySize(width: number, height: number): this {
-    return this.expandByVector(new Vector3(width / 2, height / 2, 0));
+    const { size } = this;
+
+    /* Prevent shrinking to "negative" size.
+     * If `max.x/y` becomes lower than `min.x/y`, then `isEmpty()` returns `true`
+     * and `getCenter` and `getSize` return (0, 0, 0) regardless of `min/max`. */
+    const w = width < 0 ? Math.max(width, -size.width) : width;
+    const h = height < 0 ? Math.max(height, -size.height) : height;
+
+    return this.expandByVector(new Vector3(w, h).divideScalar(2));
   }
 
   public expandToRatio(ratio: number | undefined): this {
-    if (ratio === undefined) {
+    if (ratio === undefined || ratio <= 0) {
       return this;
     }
 
     const { width, height } = this.size;
     const originalRatio = width / height;
 
-    if (originalRatio < ratio) {
+    if (ratio > originalRatio) {
       return this.expandBySize(height * ratio - width, 0); // increase width
     }
 
diff --git a/packages/lib/src/toolbar/controls/Histogram/Histogram.tsx b/packages/lib/src/toolbar/controls/Histogram/Histogram.tsx
index b5f12222..6a59e0b5 100644
--- a/packages/lib/src/toolbar/controls/Histogram/Histogram.tsx
+++ b/packages/lib/src/toolbar/controls/Histogram/Histogram.tsx
@@ -2,6 +2,7 @@ import type { Domain, ScaleType } from '@h5web/shared';
 import { useMeasure } from '@react-hookz/web';
 import { AxisBottom, AxisLeft } from '@visx/axis';
 import { scaleLinear } from '@visx/scale';
+import type { ReactNode } from 'react';
 
 import { useSafeDomain } from '../../../vis/heatmap/hooks';
 import { useCombinedDomain, useDomain } from '../../../vis/hooks';
@@ -65,7 +66,7 @@ function Histogram(props: Props) {
 
   const markerPositions = safeValue.map(xScale) as Domain;
 
-  const rects: JSX.Element[] = [];
+  const rects: ReactNode[] = [];
   values.forEach((d, i) =>
     rects.push(
       <rect
@@ -82,7 +83,7 @@ function Histogram(props: Props) {
   return (
     <div ref={ref} className={styles.container}>
       <svg width="100%" height="100%" className={styles.histogram}>
-        {...rects}
+        {rects}
         {colorMap && (
           <ColorBar
             x={markerPositions[0]}


Test patch:
diff --git a/packages/lib/src/interactions/box.test.ts b/packages/lib/src/interactions/box.test.ts
new file mode 100644
index 00000000..421d5530
--- /dev/null
+++ b/packages/lib/src/interactions/box.test.ts
@@ -0,0 +1,209 @@
+import { Vector3 } from 'three';
+
+import Box from './box';
+
+const ZERO_COORDS = { x: 0, y: 0, z: 0 };
+
+describe('size', () => {
+  it('should compute box size', () => {
+    const box1 = Box.empty();
+    expect(box1.size).toEqual({ width: 0, height: 0 });
+
+    const box2 = Box.empty(new Vector3(10, 10));
+    expect(box2.size).toEqual({ width: 0, height: 0 });
+
+    const box3 = Box.empty().expandByPoint(new Vector3(10, -5));
+    expect(box3.size).toEqual({ width: 10, height: 5 });
+  });
+});
+
+describe('center', () => {
+  it('should compute box center', () => {
+    const box1 = Box.empty();
+    expect(box1.center).toMatchObject(ZERO_COORDS);
+
+    const box2 = Box.empty(new Vector3(10, 10));
+    expect(box2.center).toMatchObject({ x: 10, y: 10, z: 0 });
+
+    const box3 = Box.empty().expandByPoint(new Vector3(10, -5));
+    expect(box3.center).toMatchObject({ x: 5, y: -2.5, z: 0 });
+  });
+});
+
+describe('empty', () => {
+  it('should create empty box at (0, 0, 0)', () => {
+    const box = Box.empty();
+
+    expect(box.size).toEqual({ width: 0, height: 0 });
+    expect(box.center).toMatchObject(ZERO_COORDS);
+
+    // Check that `min` and `max` are not (+∞, +∞, +∞)` and (-∞, -∞, -∞) respectively, which are the defaults when calling `new Box()`
+    expect(box.min).toMatchObject(ZERO_COORDS);
+    expect(box.max).toMatchObject(ZERO_COORDS);
+    expect(box.min).not.toBe(box.max); // different `Vector3` instances
+  });
+
+  it('should create empty box at given coordinates', () => {
+    const box = Box.empty(new Vector3(10, 20));
+
+    const expectedCoords = { x: 10, y: 20, z: 0 };
+    expect(box.size).toEqual({ width: 0, height: 0 });
+    expect(box.center).toMatchObject(expectedCoords);
+    expect(box.min).toMatchObject(expectedCoords);
+    expect(box.max).toMatchObject(expectedCoords);
+  });
+});
+
+describe('fromPoints', () => {
+  it('should create box that includes all given points', () => {
+    const pt1 = new Vector3(10, 10);
+    const pt2 = new Vector3(-5, 12);
+    const box = Box.fromPoints(pt1, pt2);
+
+    expect(box.containsPoint(pt1)).toBe(true);
+    expect(box.containsPoint(pt2)).toBe(true);
+    expect(box.min).toMatchObject({ x: -5, y: 10 }); // not the same as pt1
+    expect(box.max).toMatchObject({ x: 10, y: 12 }); // not the same as pt2
+  });
+
+  it('should create empty box when called with single point', () => {
+    const box = Box.fromPoints(new Vector3(-5, 12));
+    expect(box.size).toEqual({ width: 0, height: 0 });
+    expect(box.center).toMatchObject({ x: -5, y: 12 });
+  });
+});
+
+describe('fromSize', () => {
+  it('should create box with given size centered on (0, 0, 0)', () => {
+    const size = { width: 10, height: 20 };
+    const box = Box.fromSize(size);
+
+    expect(box.size).toEqual(size);
+    expect(box.center).toMatchObject(ZERO_COORDS);
+  });
+});
+
+describe('clampPoint', () => {
+  it('should clamp given point to box', () => {
+    const box = Box.empty().expandByPoint(new Vector3(5, 15)); // min: (0, 0, 0); max: (5, 15, 0)
+
+    const pt = new Vector3(10, 10);
+    const clampedPt = box.clampPoint(pt);
+
+    expect(clampedPt).toMatchObject({ x: 5, y: 10, z: 0 });
+    expect(clampedPt).not.toBe(pt); // new `Vector3` instance
+  });
+});
+
+describe('expandBySize', () => {
+  it('should expand box by given positive width/height', () => {
+    const box1 = Box.empty().expandBySize(10, 20);
+    expect(box1.size).toEqual({ width: 10, height: 20 });
+    expect(box1.center).toMatchObject(ZERO_COORDS); // no shift
+
+    const box2 = Box.empty(new Vector3(-5, 10)).expandBySize(10, 20);
+    expect(box2.size).toEqual({ width: 10, height: 20 });
+    expect(box2.center).toMatchObject({ x: -5, y: 10 }); // no shift
+  });
+
+  it('should shrink box by given negative width/height', () => {
+    const box = Box.fromSize({ width: 10, height: 20 }).expandBySize(-5, -25);
+    expect(box.size).toEqual({ width: 5, height: 0 });
+  });
+});
+
+describe('expandToRatio', () => {
+  it('should expand box to given ratio', () => {
+    const box1 = Box.empty().expandBySize(10, 10).expandToRatio(2);
+    expect(box1.size).toEqual({ width: 20, height: 10 });
+    expect(box1.center).toMatchObject(ZERO_COORDS); // no shift
+
+    const box2 = Box.empty().expandBySize(10, 10).expandToRatio(0.5);
+    expect(box2.size).toEqual({ width: 10, height: 20 });
+    expect(box2.center).toMatchObject(ZERO_COORDS); // no shift
+  });
+
+  it('should have no effect on empty box', () => {
+    const rawBox1 = Box.empty();
+    const box1 = rawBox1.clone().expandToRatio(2);
+    expect(box1.min).toEqual(rawBox1.min);
+    expect(box1.max).toEqual(rawBox1.max);
+
+    const rawBox2 = Box.empty(new Vector3(10, 10));
+    const box2 = rawBox2.clone().expandToRatio(2);
+    expect(box2.min).toEqual(rawBox2.min);
+    expect(box2.max).toEqual(rawBox2.max);
+  });
+
+  it('should have no effect when given invalid ratio', () => {
+    const rawBox1 = Box.empty(new Vector3(10, 10)).expandBySize(20, 20);
+    const box1 = rawBox1.clone().expandToRatio(undefined); // undefined
+    expect(box1.min).toEqual(rawBox1.min);
+    expect(box1.max).toEqual(rawBox1.max);
+
+    const rawBox2 = Box.empty(new Vector3(10, 10)).expandBySize(20, 20);
+    const box2 = rawBox2.clone().expandToRatio(0); // zero
+    expect(box2.min).toEqual(rawBox2.min);
+    expect(box2.max).toEqual(rawBox2.max);
+
+    const rawBox3 = Box.empty(new Vector3(10, 10)).expandBySize(20, 20);
+    const box3 = rawBox3.clone().expandToRatio(-2); // negative
+    expect(box3.min).toEqual(rawBox3.min);
+    expect(box3.max).toEqual(rawBox3.max);
+  });
+});
+
+describe('hasMinSize', () => {
+  it('should check if box has at least given width and height', () => {
+    const box1 = Box.empty();
+    expect(box1.hasMinSize(0)).toBe(true);
+    expect(box1.hasMinSize(0, 0)).toBe(true);
+    expect(box1.hasMinSize(0, 1)).toBe(false);
+    expect(box1.hasMinSize(1, 0)).toBe(false);
+
+    const box2 = Box.fromSize({ width: 20, height: 10 });
+    expect(box2.hasMinSize(10)).toBe(true);
+    expect(box2.hasMinSize(15)).toBe(false); // min height defaults to given min width
+    expect(box2.hasMinSize(15, 10)).toBe(true);
+  });
+});
+
+describe('keepWithin', () => {
+  it('should shift box within bounds of bigger given box', () => {
+    const box = Box.empty(new Vector3(10, 10)).expandBySize(10, 10);
+    const boundingBox = Box.empty().expandBySize(20, 20);
+    expect(boundingBox.containsBox(box)).toBe(false); // partially outside
+
+    const shiftedBox = box.clone().keepWithin(boundingBox);
+    expect(boundingBox.containsBox(shiftedBox)).toBe(true); // now inside
+
+    expect(shiftedBox.size).toEqual(box.size); // same size
+    expect(shiftedBox.center).not.toEqual(box.center); // ... but shifted
+  });
+
+  it('should center box around smaller given box', () => {
+    const box = Box.empty(new Vector3(10, 10)).expandBySize(20, 20);
+    const boundingBox = Box.empty().expandBySize(10, 10);
+    expect(boundingBox.containsBox(box)).toBe(false); // completely outside
+
+    const shiftedBox = box.clone().keepWithin(boundingBox);
+    expect(boundingBox.containsBox(shiftedBox)).toBe(false); // now around, not inside
+
+    expect(shiftedBox.size).toEqual(box.size); // same size
+    expect(shiftedBox.center).not.toEqual(box.center); // ... but shifted
+    expect(shiftedBox.center).toEqual(boundingBox.center); // ... and centered around bounding box
+  });
+});
+
+describe('toRect', () => {
+  it('should convert box to `Rect` array', () => {
+    const box1 = Box.empty();
+    expect(box1.toRect()).toEqual([new Vector3(), new Vector3()]);
+
+    const box2 = Box.fromPoints(new Vector3(-10, 10), new Vector3(10, -10));
+    expect(box2.toRect()).toEqual([
+      new Vector3(-10, -10, 0),
+      new Vector3(10, 10, 0),
+    ]);
+  });
+});