Repository: silx-kit/h5web (ts)

Issue:
[NeXus] Axes datasets with decreasing values break the tooltip
### Describe the bug

Displaying a NXData group that has an `axes` dataset with decreasing values breaks the tooltip:
- Data value is shown as `NaN`
- The corresponding axis value is wrong

Note that the visualization seem to render fine.

### To Reproduce

1. Go to https://h5web.panosc.eu/?file=201805_WSe2_arpes.nxs
2. Navigate to `/entry/data` (that has a decreasing axes dataset `energies`)
3. Hover the mouse on NXImage visualisation
4. See the wrong values in the tooltip

### Expected behaviour

The tooltip values should be right. This is maybe due to an oversight in the `valueToIndex` scale.




Pull request:
Allow descending values in abscissas and ordinates of Heatmap and LineVis
Fix #822 

Patch:
diff --git a/apps/storybook/src/HeatmapVis.stories.tsx b/apps/storybook/src/HeatmapVis.stories.tsx
index 574dfb32..977447fd 100644
--- a/apps/storybook/src/HeatmapVis.stories.tsx
+++ b/apps/storybook/src/HeatmapVis.stories.tsx
@@ -64,6 +64,24 @@ AxisValues.args = {
   },
 };
 
+export const DescendingAxisValues = Template.bind({});
+DescendingAxisValues.args = {
+  dataArray,
+  domain,
+  abscissaParams: {
+    value: Array.from(
+      { length: dataArray.shape[1] }, // works even when right edge of last pixel is not provided
+      (_, i) => -100 - 10 * i
+    ),
+  },
+  ordinateParams: {
+    value: Array.from(
+      { length: dataArray.shape[0] + 1 },
+      (_, i) => (5 - 0.5 * i) / 100
+    ),
+  },
+};
+
 export const Alpha = Template.bind({});
 Alpha.args = {
   dataArray,
diff --git a/apps/storybook/src/LineVis.stories.tsx b/apps/storybook/src/LineVis.stories.tsx
index 62209f57..6a87f268 100644
--- a/apps/storybook/src/LineVis.stories.tsx
+++ b/apps/storybook/src/LineVis.stories.tsx
@@ -24,6 +24,10 @@ const combinedDomain = getCombinedDomain(
   getDomains([primaryArray, secondaryArray, tertiaryArray])
 );
 
+const abscissas = Array.from(
+  { length: dataArray.size },
+  (_, i) => -10 + 0.5 * i
+);
 const errorsArray = ndarray(
   Array.from({ length: dataArray.size }, (_, i) => Math.abs(10 - 0.5 * i)),
   dataArray.shape
@@ -51,7 +55,17 @@ Abscissas.args = {
   dataArray,
   domain,
   abscissaParams: {
-    value: Array.from({ length: dataArray.size }, (_, i) => -10 + 0.5 * i),
+    value: abscissas,
+  },
+};
+
+export const DescendingAbscissas = Template.bind({});
+
+DescendingAbscissas.args = {
+  dataArray,
+  domain,
+  abscissaParams: {
+    value: [...abscissas].reverse(),
   },
 };
 
diff --git a/packages/lib/src/vis/heatmap/HeatmapVis.tsx b/packages/lib/src/vis/heatmap/HeatmapVis.tsx
index c69245bf..49654b9d 100644
--- a/packages/lib/src/vis/heatmap/HeatmapVis.tsx
+++ b/packages/lib/src/vis/heatmap/HeatmapVis.tsx
@@ -3,7 +3,7 @@ import { assertDefined, formatTooltipVal, ScaleType } from '@h5web/shared';
 import type { NdArray } from 'ndarray';
 import type { ReactElement, ReactNode } from 'react';
 
-import { useDomain, useValueToIndexScale } from '../hooks';
+import { useAxisDomain, useValueToIndexScale } from '../hooks';
 import type { VisScaleType, AxisParams } from '../models';
 import PanZoomMesh from '../shared/PanZoomMesh';
 import TooltipMesh from '../shared/TooltipMesh';
@@ -59,11 +59,11 @@ function HeatmapVis(props: Props) {
   const { rows, cols } = getDims(dataArray);
 
   const abscissas = useAxisValues(abscissaValue, cols);
-  const abscissaDomain = useDomain(abscissas);
+  const abscissaDomain = useAxisDomain(abscissas);
   assertDefined(abscissaDomain, 'Abscissas have undefined domain');
 
   const ordinates = useAxisValues(ordinateValue, rows);
-  const ordinateDomain = useDomain(ordinates);
+  const ordinateDomain = useAxisDomain(ordinates);
   assertDefined(ordinateDomain, 'Ordinates have undefined domain');
 
   const abscissaToIndex = useValueToIndexScale(abscissas);
diff --git a/packages/lib/src/vis/hooks.ts b/packages/lib/src/vis/hooks.ts
index d8c5a6ee..dbd7d086 100644
--- a/packages/lib/src/vis/hooks.ts
+++ b/packages/lib/src/vis/hooks.ts
@@ -7,10 +7,15 @@ import type { RefCallback } from 'react';
 import { createMemo } from 'react-use';
 
 import type { Size } from './models';
-import { getCombinedDomain, getValueToIndexScale } from './utils';
+import {
+  getAxisDomain,
+  getCombinedDomain,
+  getValueToIndexScale,
+} from './utils';
 
 const useBounds = createMemo(getBounds);
 const useValidDomainForScale = createMemo(getValidDomainForScale);
+export const useAxisDomain = createMemo(getAxisDomain);
 
 export function useDomain(
   valuesArray: NdArray<number[]> | number[],
diff --git a/packages/lib/src/vis/line/LineVis.tsx b/packages/lib/src/vis/line/LineVis.tsx
index 0c7be116..e8b80b68 100644
--- a/packages/lib/src/vis/line/LineVis.tsx
+++ b/packages/lib/src/vis/line/LineVis.tsx
@@ -11,12 +11,16 @@ import type { NdArray } from 'ndarray';
 import { useMemo } from 'react';
 import type { ReactElement, ReactNode } from 'react';
 
-import { useCSSCustomProperties, useValueToIndexScale } from '../hooks';
+import {
+  useAxisDomain,
+  useCSSCustomProperties,
+  useValueToIndexScale,
+} from '../hooks';
 import type { AxisParams } from '../models';
 import PanZoomMesh from '../shared/PanZoomMesh';
 import TooltipMesh from '../shared/TooltipMesh';
 import VisCanvas from '../shared/VisCanvas';
-import { getDomain, extendDomain, DEFAULT_DOMAIN } from '../utils';
+import { extendDomain, DEFAULT_DOMAIN } from '../utils';
 import DataCurve from './DataCurve';
 import styles from './LineVis.module.css';
 import type { TooltipData } from './models';
@@ -75,10 +79,7 @@ function LineVis(props: Props) {
 
   const abscissaToIndex = useValueToIndexScale(abscissas, true);
 
-  const abscissaDomain = useMemo(() => {
-    const rawDomain = getDomain(abscissas, abscissaScaleType);
-    return rawDomain && extendDomain(rawDomain, 0.01, abscissaScaleType);
-  }, [abscissas, abscissaScaleType]);
+  const abscissaDomain = useAxisDomain(abscissas, abscissaScaleType, 0.01);
 
   assertDefined(abscissaDomain, 'Abscissas have undefined domain');
 
diff --git a/packages/lib/src/vis/utils.ts b/packages/lib/src/vis/utils.ts
index 70a8176f..4beaf51c 100644
--- a/packages/lib/src/vis/utils.ts
+++ b/packages/lib/src/vis/utils.ts
@@ -157,17 +157,23 @@ export function getValueToIndexScale(
   values: number[],
   switchAtMidpoints?: boolean
 ): ScaleThreshold<number, number> {
-  const indices = range(values.length);
-
-  const thresholds = switchAtMidpoints
+  const rawThresholds = switchAtMidpoints
     ? values.map((_, i) => values[i - 1] + (values[i] - values[i - 1]) / 2) // Shift the thresholds for the switch from i-1 to i to happen between values[i-1] and values[i]
     : values; // Else, the switch from i-1 to i will happen at values[i]
 
   // First threshold (going from 0 to 1) should be for the second value. Scaling the first value should return at 0.
-  return scaleThreshold<number, number>({
-    domain: thresholds.slice(1),
-    range: indices,
-  });
+  const thresholds = rawThresholds.slice(1);
+  const indices = range(values.length);
+
+  // ScaleThreshold only works with ascending values so the scale is reversed for descending values
+  return scaleThreshold<number, number>(
+    isDescending(thresholds)
+      ? {
+          domain: [...thresholds].reverse(),
+          range: [...indices].reverse(),
+        }
+      : { domain: thresholds, range: indices }
+  );
 }
 
 export function getCanvasScale(
@@ -262,3 +268,22 @@ export function getAxisOffsets(
     top: hasLabel.top ? horizontal : fallback,
   };
 }
+
+export function isDescending(array: number[]): boolean {
+  return array[array.length - 1] - array[0] < 0;
+}
+
+export function getAxisDomain(
+  axisValues: number[],
+  scaleType: ScaleType = ScaleType.Linear,
+  extensionFactor = 0
+): Domain | undefined {
+  const rawDomain = getDomain(axisValues, scaleType);
+  if (!rawDomain) {
+    return undefined;
+  }
+  const extendedDomain = extendDomain(rawDomain, extensionFactor, scaleType);
+  return isDescending(axisValues)
+    ? [extendedDomain[1], extendedDomain[0]]
+    : extendedDomain;
+}


Test patch:
diff --git a/packages/lib/src/vis/utils.test.ts b/packages/lib/src/vis/utils.test.ts
index 01fe9c12..cd9e8bcb 100644
--- a/packages/lib/src/vis/utils.test.ts
+++ b/packages/lib/src/vis/utils.test.ts
@@ -266,6 +266,30 @@ describe('getValueToIndexScale', () => {
     expect(scale(25)).toEqual(2);
     expect(scale(100)).toEqual(2);
   });
+
+  it('should create threshold scale from descending values to indices', () => {
+    const scale = getValueToIndexScale([30, 20, 10]);
+
+    expect(scale(100)).toEqual(0);
+    expect(scale(20)).toEqual(0);
+    expect(scale(19.9)).toEqual(1);
+    expect(scale(10)).toEqual(1);
+    expect(scale(9.9)).toEqual(2);
+    expect(scale(0)).toEqual(2);
+  });
+
+  it('should allow scale with descending values to switch at midpoints', () => {
+    const scale = getValueToIndexScale([30, 20, 10], true);
+
+    expect(scale(100)).toEqual(0);
+    expect(scale(30)).toEqual(0);
+    expect(scale(25)).toEqual(0);
+    expect(scale(24.9)).toEqual(1);
+    expect(scale(20)).toEqual(1);
+    expect(scale(15)).toEqual(1);
+    expect(scale(14.9)).toEqual(2);
+    expect(scale(0)).toEqual(2);
+  });
 });
 
 describe('getIntegerTicks', () => {