Repository: silx-kit/h5web
Pull request #904 (2021-12-13 13:43:50)

Measure canvas area only when a specific canvas ratio is requested
The goal is to avoid unnecessary re-renders of the heatmap vis' canvas when the layout is `fill` or `cover` (which is always the case in @h5web/app).

To make this behaviour a bit more readable, I actually reworked a few things:

- I merged `computeVisSize` and `useVisSize` into a single `getSizeToFit`, which takes an `availableSize` and a `ratioToRespect`.
- I moved the axis offsets padding to the measured container (now called `canvasArea` instead of `visArea`), removed the early return, etc.

As mentioned in a comment, one issue is that when the layout changes to/from `contain` dynamically (i.e. when the `canvasRatio` prop of `VisCanvas` changes between a number and `undefined`), the measuring either doesn't start (and nothing gets rendered), or doesn't stop (everything keeps re-rendering on resize).


Patch:
diff --git a/apps/storybook/src/Utilities.stories.mdx b/apps/storybook/src/Utilities.stories.mdx
index e48bd8c9..c9649b2a 100644
--- a/apps/storybook/src/Utilities.stories.mdx
+++ b/apps/storybook/src/Utilities.stories.mdx
@@ -55,16 +55,6 @@ const extendedDomain3 = extendDomain([2, 2], 0.5); // [1, 3]
 const extendedDomain4 = extendDomain([1, 1], 1, ScaleType.Log); // [0.1, 10]
 ```
 
-#### computeCanvasSize
-
-Compute the optimal size for a visualization based on the available size and an aspect ratio. If `aspecRatio` is not provided, `availableSize` is returned unchanged.
-
-```ts
-computeCanvasSize(availableSize: Size, aspectRatio?: number): Size | undefined
-
-const size = computeCanvasSize({ width: 20, height: 10 }, 1.5); // { width: 15, height: 10 }
-```
-
 #### getLinearGradient
 
 Generate a CSS linear gradient for a given D3 interpolator, to be used as `background-image`. If `minMaxOnly` is `true`, the gradient will include only two colours stops.
diff --git a/packages/app/src/visualizer/VisManager.tsx b/packages/app/src/visualizer/VisManager.tsx
index f60ad59d..e94e4ae5 100644
--- a/packages/app/src/visualizer/VisManager.tsx
+++ b/packages/app/src/visualizer/VisManager.tsx
@@ -49,7 +49,7 @@ function VisManager(props: Props) {
         />
       </div>
 
-      <div className={styles.displayArea}>
+      <div className={styles.visArea}>
         <Profiler id={activeVis.name}>
           <Container
             key={entity.path}
diff --git a/packages/app/src/visualizer/Visualizer.module.css b/packages/app/src/visualizer/Visualizer.module.css
index 18eee9bd..4ab0b4b4 100644
--- a/packages/app/src/visualizer/Visualizer.module.css
+++ b/packages/app/src/visualizer/Visualizer.module.css
@@ -15,7 +15,7 @@
   background-color: var(--secondary-light-bg);
 }
 
-.displayArea {
+.visArea {
   flex: 1 1 0%;
   min-height: 0;
   display: flex;
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
index 032e80af..5f983034 100644
--- a/packages/lib/src/index.ts
+++ b/packages/lib/src/index.ts
@@ -45,7 +45,6 @@ export type { AxisSystemParams } from './vis/shared/AxisSystemContext';
 
 // Utilities
 export {
-  computeCanvasSize,
   getDomain,
   getDomains,
   getCombinedDomain,
diff --git a/packages/lib/src/vis/heatmap/HeatmapVis.tsx b/packages/lib/src/vis/heatmap/HeatmapVis.tsx
index 47e07df7..bef2d535 100644
--- a/packages/lib/src/vis/heatmap/HeatmapVis.tsx
+++ b/packages/lib/src/vis/heatmap/HeatmapVis.tsx
@@ -73,8 +73,8 @@ function HeatmapVis(props: Props) {
     <figure className={styles.root} aria-label={title} data-keep-canvas-colors>
       <VisCanvas
         title={title}
-        aspectRatio={layout === 'contain' ? cols / rows : undefined}
-        visRatio={layout === 'cover' ? cols / rows : undefined}
+        canvasRatio={layout === 'contain' ? cols / rows : undefined}
+        visRatio={layout !== 'fill' ? cols / rows : undefined}
         abscissaConfig={{
           visDomain: abscissaDomain,
           showGrid,
diff --git a/packages/lib/src/vis/hooks.ts b/packages/lib/src/vis/hooks.ts
index dbd7d086..291d3d88 100644
--- a/packages/lib/src/vis/hooks.ts
+++ b/packages/lib/src/vis/hooks.ts
@@ -6,7 +6,6 @@ import { useCallback, useMemo, useState } from 'react';
 import type { RefCallback } from 'react';
 import { createMemo } from 'react-use';
 
-import type { Size } from './models';
 import {
   getAxisDomain,
   getCombinedDomain,
@@ -48,21 +47,6 @@ export function useDomains(
   );
 }
 
-export function useVisSize(ratioToRespect: number | undefined): Size {
-  const { width, height } = useThree((state) => state.size);
-
-  if (!ratioToRespect) {
-    return { width, height };
-  }
-
-  const canvasRatio = width / height;
-
-  return {
-    width: canvasRatio > ratioToRespect ? height * ratioToRespect : width,
-    height: canvasRatio < ratioToRespect ? width / ratioToRespect : height,
-  };
-}
-
 function onWheel(evt: WheelEvent) {
   evt.preventDefault();
 }
diff --git a/packages/lib/src/vis/rgb/RgbVis.tsx b/packages/lib/src/vis/rgb/RgbVis.tsx
index 363ab0f7..a615043f 100644
--- a/packages/lib/src/vis/rgb/RgbVis.tsx
+++ b/packages/lib/src/vis/rgb/RgbVis.tsx
@@ -63,8 +63,8 @@ function RgbVis(props: Props) {
     <figure className={styles.root} aria-label={title} data-keep-canvas-colors>
       <VisCanvas
         title={title}
-        aspectRatio={layout === 'contain' ? cols / rows : undefined}
-        visRatio={layout === 'cover' ? cols / rows : undefined}
+        canvasRatio={layout === 'contain' ? cols / rows : undefined}
+        visRatio={layout !== 'fill' ? cols / rows : undefined}
         abscissaConfig={{
           visDomain: [0, cols],
           showGrid,
diff --git a/packages/lib/src/vis/shared/AxisSystemProvider.tsx b/packages/lib/src/vis/shared/AxisSystemProvider.tsx
index 4a31a074..d2ff33b2 100644
--- a/packages/lib/src/vis/shared/AxisSystemProvider.tsx
+++ b/packages/lib/src/vis/shared/AxisSystemProvider.tsx
@@ -1,21 +1,22 @@
-import type { ReactNode } from 'react';
+import { useThree } from '@react-three/fiber';
+import type { PropsWithChildren } from 'react';
 
-import { useVisSize } from '../hooks';
 import type { AxisConfig } from '../models';
-import { getCanvasScale } from '../utils';
+import { getSizeToFit, getCanvasScale } from '../utils';
 import { AxisSystemContext } from './AxisSystemContext';
 
 interface Props {
-  visRatio?: number;
+  visRatio: number | undefined;
   abscissaConfig: AxisConfig;
   ordinateConfig: AxisConfig;
-  children: ReactNode;
 }
 
-function AxisSystemProvider(props: Props) {
+function AxisSystemProvider(props: PropsWithChildren<Props>) {
   const { visRatio, abscissaConfig, ordinateConfig, children } = props;
 
-  const visSize = useVisSize(visRatio);
+  const availableSize = useThree((state) => state.size);
+  const visSize = getSizeToFit(availableSize, visRatio);
+
   const abscissaScale = getCanvasScale(abscissaConfig, visSize.width);
   const ordinateScale = getCanvasScale(ordinateConfig, visSize.height);
 
@@ -34,5 +35,4 @@ function AxisSystemProvider(props: Props) {
   );
 }
 
-export type { Props as AxisSystemProviderProps };
 export default AxisSystemProvider;
diff --git a/packages/lib/src/vis/shared/VisCanvas.module.css b/packages/lib/src/vis/shared/VisCanvas.module.css
index 4ecd5412..e8795353 100644
--- a/packages/lib/src/vis/shared/VisCanvas.module.css
+++ b/packages/lib/src/vis/shared/VisCanvas.module.css
@@ -1,15 +1,16 @@
-.visArea {
+.canvasArea {
   position: relative;
   flex: 1 1 0%;
   overflow: hidden; /* prevent overflow when resizing */
 }
 
-.vis {
-  position: absolute; /* prevent feedback loop with `useMeasure` on wrapper */
+.canvasWrapper {
+  width: 100%; /* fill container by default (unless size is passed in JSX) */
+  height: 100%;
+  margin: 0 auto;
 }
 
-/* R3F's root element */
-.canvasWrapper {
+.r3fRoot {
   overflow: visible !important; /* show child axis grid, which is bigger than canvas */
   background-color: var(--h5w-canvas--bgColor, transparent);
 }
diff --git a/packages/lib/src/vis/shared/VisCanvas.tsx b/packages/lib/src/vis/shared/VisCanvas.tsx
index 879603bb..4b93670e 100644
--- a/packages/lib/src/vis/shared/VisCanvas.tsx
+++ b/packages/lib/src/vis/shared/VisCanvas.tsx
@@ -1,25 +1,34 @@
 import { useMeasure } from '@react-hookz/web';
 import { Canvas } from '@react-three/fiber';
+import type { PropsWithChildren } from 'react';
 
-import { computeCanvasSize, getAxisOffsets } from '../utils';
+import type { AxisConfig } from '../models';
+import { getSizeToFit, getAxisOffsets } from '../utils';
 import AxisSystem from './AxisSystem';
 import AxisSystemProvider from './AxisSystemProvider';
-import type { AxisSystemProviderProps } from './AxisSystemProvider';
 import styles from './VisCanvas.module.css';
 
-interface Props extends AxisSystemProviderProps {
-  aspectRatio?: number;
+interface Props {
   title?: string;
+  canvasRatio?: number | undefined;
+  visRatio?: number | undefined;
+  abscissaConfig: AxisConfig;
+  ordinateConfig: AxisConfig;
 }
 
-function VisCanvas(props: Props) {
-  const { aspectRatio, title, children, ...providerProps } = props;
-  const { abscissaConfig, ordinateConfig } = providerProps;
+function VisCanvas(props: PropsWithChildren<Props>) {
+  const {
+    title,
+    canvasRatio,
+    visRatio,
+    abscissaConfig,
+    ordinateConfig,
+    children,
+  } = props;
 
-  const [visAreaSize, visAreaRef] = useMeasure<HTMLDivElement>();
-  if (!visAreaSize) {
-    return <div ref={visAreaRef} className={styles.visArea} />;
-  }
+  const shouldMeasure = !!canvasRatio;
+  const [areaSize, areaRef] = useMeasure<HTMLDivElement>();
+  const canvasSize = areaSize && getSizeToFit(areaSize, canvasRatio);
 
   const axisOffsets = getAxisOffsets({
     left: !!ordinateConfig.label,
@@ -27,51 +36,49 @@ function VisCanvas(props: Props) {
     top: !!title,
   });
 
-  const availableSize = {
-    width: visAreaSize.width - axisOffsets.left - axisOffsets.right,
-    height: visAreaSize.height - axisOffsets.bottom - axisOffsets.top,
-  };
-
-  const visSize = computeCanvasSize(availableSize, aspectRatio);
-
   return (
-    <div ref={visAreaRef} className={styles.visArea}>
-      <div
-        className={styles.vis}
-        style={{
-          ...visSize,
-          paddingBottom: axisOffsets.bottom,
-          paddingLeft: axisOffsets.left,
-          paddingTop: axisOffsets.top,
-          paddingRight: axisOffsets.right,
-          boxSizing: 'content-box',
-        }}
-      >
-        <Canvas
-          className={styles.canvasWrapper}
-          orthographic
-          linear // disable automatic color encoding and gamma correction
-          flat // disable tone mapping
-          frameloop="demand" // disable game loop
-          dpr={[1, 3]} // https://discoverthreejs.com/tips-and-tricks/#performance
-          gl={{ preserveDrawingBuffer: true }} // for screenshot feature
-          resize={{ debounce: { scroll: 20, resize: 200 }, scroll: false }} // https://github.com/pmndrs/react-three-fiber/discussions/1906
-        >
-          <ambientLight />
-          <AxisSystemProvider {...providerProps}>
-            <AxisSystem axisOffsets={axisOffsets} title={title} />
-            <group
-              scale={[
-                abscissaConfig.flip ? -1 : 1,
-                ordinateConfig.flip ? -1 : 1,
-                1,
-              ]}
+    <div
+      ref={shouldMeasure ? areaRef : undefined} // unfortunately, this isn't enough to start/stop measuring dynamically https://github.com/react-hookz/web/issues/523
+      className={styles.canvasArea}
+      style={{
+        paddingBottom: axisOffsets.bottom,
+        paddingLeft: axisOffsets.left,
+        paddingTop: axisOffsets.top,
+        paddingRight: axisOffsets.right,
+      }}
+    >
+      {(!shouldMeasure || canvasSize) && (
+        <div className={styles.canvasWrapper} style={canvasSize}>
+          <Canvas
+            className={styles.r3fRoot}
+            orthographic
+            linear // disable automatic color encoding and gamma correction
+            flat // disable tone mapping
+            frameloop="demand" // disable game loop
+            dpr={[1, 3]} // https://discoverthreejs.com/tips-and-tricks/#performance
+            gl={{ preserveDrawingBuffer: true }} // for screenshot feature
+            resize={{ debounce: { scroll: 20, resize: 200 }, scroll: false }} // https://github.com/pmndrs/react-three-fiber/discussions/1906
+          >
+            <ambientLight />
+            <AxisSystemProvider
+              visRatio={visRatio}
+              abscissaConfig={abscissaConfig}
+              ordinateConfig={ordinateConfig}
             >
-              {children}
-            </group>
-          </AxisSystemProvider>
-        </Canvas>
-      </div>
+              <AxisSystem axisOffsets={axisOffsets} title={title} />
+              <group
+                scale={[
+                  abscissaConfig.flip ? -1 : 1,
+                  ordinateConfig.flip ? -1 : 1,
+                  1,
+                ]}
+              >
+                {children}
+              </group>
+            </AxisSystemProvider>
+          </Canvas>
+        </div>
+      )}
     </div>
   );
 }
diff --git a/packages/lib/src/vis/utils.ts b/packages/lib/src/vis/utils.ts
index bf3a95ca..0d5f0f19 100644
--- a/packages/lib/src/vis/utils.ts
+++ b/packages/lib/src/vis/utils.ts
@@ -53,27 +53,22 @@ export function createAxisScale(
   return H5WEB_SCALES[ScaleType.Gamma].createScale({ ...config, exponent });
 }
 
-export function computeCanvasSize(
+export function getSizeToFit(
   availableSize: Size,
-  aspectRatio?: number
-): Size | undefined {
+  ratioToRespect: number | undefined
+): Size {
   const { width, height } = availableSize;
 
-  if (width <= 0 && height <= 0) {
-    return undefined;
-  }
-
-  if (!aspectRatio) {
-    return availableSize;
+  if (!ratioToRespect) {
+    return { width, height };
   }
 
-  // Determine how to compute canvas size to fit available space while maintaining aspect ratio
-  const idealHeight = width / aspectRatio;
-  const shouldReduceWidth = idealHeight > height;
+  const availableRatio = width / height;
 
-  return shouldReduceWidth
-    ? { width: height * aspectRatio, height }
-    : { width, height: width / aspectRatio };
+  return {
+    width: availableRatio > ratioToRespect ? height * ratioToRespect : width,
+    height: availableRatio < ratioToRespect ? width / ratioToRespect : height,
+  };
 }
 
 export function getDomain(


Test patch:
diff --git a/packages/lib/src/vis/utils.test.ts b/packages/lib/src/vis/utils.test.ts
index 66d48a3b..da70103d 100644
--- a/packages/lib/src/vis/utils.test.ts
+++ b/packages/lib/src/vis/utils.test.ts
@@ -3,7 +3,7 @@ import { ScaleType } from '@h5web/shared';
 import { tickStep } from 'd3-array';
 
 import {
-  computeCanvasSize,
+  getSizeToFit,
   getDomain,
   getDomains,
   extendDomain,
@@ -17,56 +17,22 @@ const MAX = Number.MAX_VALUE / 2;
 const POS_MIN = Number.MIN_VALUE;
 const { NaN: NAN, POSITIVE_INFINITY: INFINITY } = Number;
 
-describe('computeCanvasSize', () => {
-  describe('with aspect ratio > 1', () => {
-    it('should return available size with width reduced', () => {
-      const availableSize = { width: 20, height: 10 };
-      const size = computeCanvasSize(availableSize, 1.5);
-
-      expect(size?.width).toBeLessThanOrEqual(availableSize.width);
-      expect(size?.height).toBeLessThanOrEqual(availableSize.height);
-      expect(size).toEqual({ width: 10 * 1.5, height: 10 });
-    });
-
-    it('should return available size with height reduced', () => {
-      const availableSize = { width: 12, height: 50 };
-      const size = computeCanvasSize(availableSize, 3);
-
-      expect(size?.width).toBeLessThanOrEqual(availableSize.width);
-      expect(size?.height).toBeLessThanOrEqual(availableSize.height);
-      expect(size).toEqual({ width: 12, height: 12 / 3 });
-    });
+describe('getSizeToFit', () => {
+  it('should adjust width when requested ratio is greater than available ratio', () => {
+    const size = getSizeToFit({ width: 20, height: 10 }, 1.5);
+    expect(size).toEqual({ width: 10 * 1.5, height: 10 });
   });
 
-  describe('with aspect ratio < 1', () => {
-    it('should return available size with width reduced', () => {
-      const availableSize = { width: 20, height: 10 };
-      const size = computeCanvasSize(availableSize, 0.5);
-
-      expect(size?.width).toBeLessThanOrEqual(availableSize.width);
-      expect(size?.height).toBeLessThanOrEqual(availableSize.height);
-      expect(size).toEqual({ width: 10 * 0.5, height: 10 });
-    });
-
-    it('should return available size with height reduced', () => {
-      const availableSize = { width: 12, height: 50 };
-      const size = computeCanvasSize(availableSize, 0.75);
-
-      expect(size?.width).toBeLessThanOrEqual(availableSize.width);
-      expect(size?.height).toBeLessThanOrEqual(availableSize.height);
-      expect(size).toEqual({ width: 12, height: 12 / 0.75 });
-    });
+  it('should adjust height when request ratio is lower than available ratio', () => {
+    const availableSize = { width: 12, height: 50 };
+    const size = getSizeToFit(availableSize, 3);
+    expect(size).toEqual({ width: 12, height: 12 / 3 });
   });
 
   it('should return available size when no aspect ratio is provided', () => {
-    const size = computeCanvasSize({ width: 20, height: 10 }, undefined);
+    const size = getSizeToFit({ width: 20, height: 10 }, undefined);
     expect(size).toEqual({ width: 20, height: 10 });
   });
-
-  it('should return `undefined` when no space is available for visualization', () => {
-    const size = computeCanvasSize({ width: 0, height: 0 }, 1);
-    expect(size).toBeUndefined();
-  });
 });
 
 describe('getDomain', () => {