diff --git a/apps/storybook/src/HeatmapVis.stories.tsx b/apps/storybook/src/HeatmapVis.stories.tsx index 574dfb32c..977447fdc 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 62209f574..6a87f268b 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 c69245bfc..49654b9d5 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 d8c5a6ee3..dbd7d086d 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[], diff --git a/packages/lib/src/vis/line/LineVis.tsx b/packages/lib/src/vis/line/LineVis.tsx index 0c7be1162..e8b80b68a 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.test.ts b/packages/lib/src/vis/utils.test.ts index 01fe9c127..cd9e8bcbe 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', () => { diff --git a/packages/lib/src/vis/utils.ts b/packages/lib/src/vis/utils.ts index 70a8176fd..4beaf51c7 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 { - 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({ - 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( + 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; +}