Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dist*/

/coverage/
/packages/app/src/__tests__/__screenshots__/
.vitest-attachments/
/support/h5grove/__pycache__/
/support/hsds/hsdstest/

Expand Down
27 changes: 27 additions & 0 deletions apps/storybook/src/DimensionMapper.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,30 @@ export const WithSlicingControls = Default.extend({
withSlicingControls: true,
},
});

export const MultiPlotting = Default.extend({
render: (args) => {
const { dimMapping: initialMapping, ...otherArgs } = args;
const [dimMapping, setDimMapping] = useState(initialMapping);
const [multiDim, setMultiDim] = useState<number>();

return (
<div style={{ display: 'flex' }}>
<DimensionMapper
{...otherArgs}
dimMapping={dimMapping}
onChange={setDimMapping}
multiDim={multiDim}
onMultiDimChange={setMultiDim}
/>
<pre style={{ padding: '0 1rem', fontSize: '125%' }}>
{JSON.stringify({ dimMapping, multiDim })}
</pre>
</div>
);
},
args: {
dims: [5, 10, 25, 15],
dimMapping: [2, 5, 0, 'x'],
},
});
Binary file modified cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified cypress/snapshots/app.cy.ts/nxline.snap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions packages/app/src/__tests__/DimensionMapper.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,65 @@ test('display one slider and two mappers when visualizing 3D dataset as Matrix',
expect(page.getByRole('slider', { name: 'D1' })).not.toBeInTheDocument();
});

test('plot all slices of 2D dataset as Line', async () => {
await renderApp({
initialPath: '/arrays/twoD',
preferredVis: Vis.Line,
});

const checkbox = page.getByRole('checkbox', { name: 'Plot all D0 slices' });
expect(checkbox).toBeEnabled();
expect(checkbox).not.toBeChecked();
expect(page.getByTestId('0,x')).toBeVisible();

await checkbox.click();
expect(checkbox).toBeChecked();
await expect.element(page.getByTestId('y,x')).toBeVisible();

await checkbox.click();
expect(checkbox).not.toBeChecked();
await expect.element(page.getByTestId('0,x')).toBeVisible();
});

test('allow only one multi-plotting dimension at a time', async () => {
await renderApp({
initialPath: '/arrays/threeD',
preferredVis: Vis.Line,
});

const d0Checkbox = page.getByRole('checkbox', { name: 'Plot all D0 slices' });
const d1Checkbox = page.getByRole('checkbox', { name: 'Plot all D1 slices' });

await d1Checkbox.click();
expect(d1Checkbox).toBeChecked();
expect(d0Checkbox).toBeDisabled();
await expect.element(page.getByTestId('0,y,x')).toBeVisible();

// Mapping multi-plotting dimension to x axis disables multi-plotting
await getDimMappingBtn('x', 1).click();
expect(d0Checkbox).toBeEnabled();
expect(d0Checkbox).not.toBeChecked();
await expect.element(page.getByTestId('0,x,0')).toBeVisible();

// Dimension too long to plot all slices at once
expect(
page.getByRole('checkbox', { name: 'Plot all D2 slices' }),
).toBeDisabled();
});

test('show multi-plotting checkboxes only on Line visualization', async () => {
const { selectVisTab } = await renderApp({
initialPath: '/arrays/threeD',
preferredVis: Vis.Heatmap,
});

expect(page.getByRole('slider', { name: 'D0' })).toBeVisible();
expect(page.getByRole('checkbox')).not.toBeInTheDocument();

await selectVisTab(Vis.Line);
expect(page.getByRole('checkbox')).toHaveLength(2);
});

test('slice through 2D dataset', async () => {
const { user } = await renderApp({
initialPath: '/arrays/twoD',
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/__tests__/NexusPack.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,20 @@ test('retry fetching automatically when selecting other NxHeatmap slice', async
await expect.element(page.getByRole('figure')).toBeVisible();
});

test('plot all slices of signal and errors on NxLine', async () => {
const { selectNexusExplorerNode } = await renderApp('/nexus/spectrum');

const checkbox = page.getByRole('checkbox', { name: 'Plot all D0 slices' });
await checkbox.click();
expect(checkbox).toBeChecked();
await expect.element(page.getByTestId('y,x')).toBeVisible();

// Multi-plotting is not supported alongside auxiliary signals
await selectNexusExplorerNode('spectrum_with_aux');
expect(page.getByRole('slider', { name: 'D0' })).toBeVisible();
expect(checkbox).not.toBeInTheDocument();
});

test('visualize NXnote group', async () => {
await renderApp('/nexus/NXnote');
expect(page.getByText('"energy": 10.2')).toBeVisible();
Expand Down
68 changes: 54 additions & 14 deletions packages/app/src/dim-mapping-store.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface DimMappingState {
axesCount: number;
lockedDimsCount: number;
mapping: DimensionMapping;
multiDim: number | undefined;
}

function createDimMappingStore() {
Expand All @@ -26,6 +27,7 @@ function createDimMappingStore() {
axesCount: 0,
lockedDimsCount: 0,
mapping: [],
multiDim: undefined,
}));
}

Expand All @@ -46,7 +48,12 @@ export function useDimMappingState(opts: {
axesCount: number;
lockedDimsCount?: number;
defaultSlice?: DefaultSlice;
}): [DimensionMapping, (mapping: DimensionMapping) => void] {
}): [
DimensionMapping,
(mapping: DimensionMapping) => void,
number | undefined,
(dim: number | undefined) => void,
] {
const { dims, axesCount, lockedDimsCount = 0, defaultSlice } = opts;

const store = useContext(StoreContext);
Expand All @@ -61,22 +68,55 @@ export function useDimMappingState(opts: {
: newMapping;
}, [dims, axesCount, lockedDimsCount, defaultSlice]);

const isStateValid = useCallback(
(st: DimMappingState) =>
areSameDims(dims, st.dims) &&
axesCount === st.axesCount &&
lockedDimsCount === st.lockedDimsCount,
[dims, axesCount, lockedDimsCount],
);

// Use previous dimension mapping if it is still valid
const currentMapping = isStateValid(state) ? state.mapping : mapping;
const currentMultiDim = isStateValid(state) ? state.multiDim : undefined;

// Read latest store state, as setters can be called asynchronously (e.g. debounced slicing)
const setMapping = useCallback(
(newMapping: DimensionMapping) => {
store.setState({ dims, axesCount, lockedDimsCount, mapping: newMapping });
store.setState((prevState) => {
const prevMultiDim = isStateValid(prevState)
? prevState.multiDim
: undefined;

return {
dims,
axesCount,
lockedDimsCount,
mapping: newMapping,
// Reset multi-plotting dimension if it is no longer sliceable (e.g. mapped to axis)
multiDim:
prevMultiDim !== undefined &&
typeof newMapping[prevMultiDim] === 'number'
? prevMultiDim
: undefined,
};
});
},
[dims, axesCount, lockedDimsCount, isStateValid, store],
);

const setMultiDim = useCallback(
(dim: number | undefined) => {
store.setState((prevState) => ({
dims,
axesCount,
lockedDimsCount,
mapping: isStateValid(prevState) ? prevState.mapping : mapping,
multiDim: dim,
}));
},
[dims, axesCount, lockedDimsCount, store],
[dims, axesCount, lockedDimsCount, mapping, isStateValid, store],
);

if (
areSameDims(dims, state.dims) &&
axesCount === state.axesCount &&
lockedDimsCount === state.lockedDimsCount
) {
// Previous dimension mapping is still valid, so use it
return [state.mapping, setMapping];
}

// Return new mapping
return [mapping, setMapping];
return [currentMapping, setMapping, currentMultiDim, setMultiDim];
}
25 changes: 17 additions & 8 deletions packages/app/src/vis-packs/core/line/LineVisContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
assertDataset,
assertNumericLikeType,
} from '@h5web/shared/guards';
import { useMemo } from 'react';

import { useDimMappingState } from '../../../dim-mapping-store';
import { useValuesInCache } from '../../../hooks';
Expand All @@ -13,6 +14,7 @@ import VisBoundary from '../../VisBoundary';
import ValueFetcher from '../ValueFetcher';
import { useLineConfig } from './config';
import MappedLineVis from './MappedLineVis';
import { getMultiPlotMapping } from './utils';

function LineVisContainer(props: VisContainerProps) {
const { entity, toolbarContainer } = props;
Expand All @@ -21,32 +23,39 @@ function LineVisContainer(props: VisContainerProps) {
assertNumericLikeType(entity);

const { dims } = entity.shape;
const [dimMapping, setDimMapping] = useDimMappingState({
dims,
axesCount: 1,
});
const [dimMapping, setDimMapping, multiDim, setMultiDim] = useDimMappingState(
{ dims, axesCount: 1 },
);

const lineMapping = useMemo(
() => getMultiPlotMapping(dimMapping, multiDim),
[dimMapping, multiDim],
);

const config = useLineConfig();
const selection = getSliceSelection(dimMapping);
const selection = getSliceSelection(lineMapping);
const isCached = useValuesInCache(entity);

return (
<>
<DimensionMapper
className={visualizerStyles.dimMapper}
dims={dims}
dimMapping={dimMapping}
canSliceFast={useValuesInCache(entity)}
canSliceFast={(m) => isCached(getMultiPlotMapping(m, multiDim))}
onChange={setDimMapping}
multiDim={multiDim}
onMultiDimChange={setMultiDim}
/>
<VisBoundary resetKey={dimMapping} isSlice={selection !== undefined}>
<VisBoundary resetKey={lineMapping} isSlice={selection !== undefined}>
<ValueFetcher
dataset={entity}
selection={selection}
render={(value) => (
<MappedLineVis
dataset={entity}
value={value}
dimMapping={dimMapping}
dimMapping={lineMapping}
title={entity.name}
toolbarContainer={toolbarContainer}
config={config}
Expand Down
64 changes: 52 additions & 12 deletions packages/app/src/vis-packs/core/line/MappedLineVis.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
type DimensionMapping,
getSliceSelection,
INTERPOLATORS,
KeepZoom,
LineVis,
useCombinedDomain,
Expand Down Expand Up @@ -35,7 +36,9 @@ import {
import { DEFAULT_DOMAIN, formatNumLikeType, toNumArray } from '../utils';
import { type LineConfig } from './config';
import LineToolbar from './LineToolbar';
import { generateCsv } from './utils';
import { generateCsv, getRows } from './utils';

const interpolateSliceColor = INTERPOLATORS.Viridis;

interface Props {
dataset: Dataset<ArrayShape, NumericLikeType>;
Expand Down Expand Up @@ -106,18 +109,34 @@ function MappedLineVis(props: Props) {
const numAuxErrorsArrays = useToNumArrays(auxErrors);
const numAxisArrays = useToNumArrays(axisValues);

const dataArray = useMappedArray(numArray, ...mappingArgs);
const errorsArray = useMappedArray(numErrorsArray, ...mappingArgs);
// In multi-plotting mode, the multi-plotting dimension is mapped to `y` and the mapped arrays are 2D
const mappedArray = useMappedArray(numArray, ...mappingArgs);
const mappedErrorsArray = useMappedArray(numErrorsArray, ...mappingArgs);
const auxArrays = useMappedArrays(numAuxArrays, ...mappingArgs);
const auxErrorsArrays = useMappedArrays(numAuxErrorsArrays, ...mappingArgs);

const dataDomain = useDomain(dataArray, {
errors: errorsArray,
const dataDomain = useDomain(mappedArray, {
errors: mappedErrorsArray,
includeErrors: showErrors,
scaleType: yScaleType,
ignoreValue,
});

const multiDim = dimMapping.indexOf('y');
const isMultiPlot = multiDim !== -1;

const [dataArray, ...sliceArrays] = useMemo(
() => (isMultiPlot ? getRows(mappedArray) : [mappedArray]),
[isMultiPlot, mappedArray],
);
const [errorsArray, ...sliceErrorsArrays] = useMemo(
() =>
isMultiPlot && mappedErrorsArray
? getRows(mappedErrorsArray)
: [mappedErrorsArray],
[isMultiPlot, mappedErrorsArray],
);

const auxDomains = useDomains(auxArrays, {
errorsArrays: auxErrorsArrays,
includeErrors: showErrors,
Expand All @@ -143,17 +162,36 @@ function MappedLineVis(props: Props) {
scaleType: xScaleType,
};

const auxiliaries = auxArrays.map((array, i) => ({
label: auxLabels[i],
array,
errors: auxErrorsArrays[i],
visible: auxVisible[i],
}));
function getSliceLabel(index: number) {
return `D${multiDim}[${index}]`;
}

// Sample sequential colour scale along multi-plotting dimension (avoiding lightest colours)
function getSliceColor(index: number) {
return interpolateSliceColor(
(0.85 * index) / Math.max(sliceArrays.length, 1),
);
}

const auxiliaries = isMultiPlot
? sliceArrays.map((array, i) => ({
label: getSliceLabel(i + 1),
array,
errors: sliceErrorsArrays[i],
color: getSliceColor(i + 1),
visible: true,
}))
: auxArrays.map((array, i) => ({
label: auxLabels[i],
array,
errors: auxErrorsArrays[i],
visible: auxVisible[i],
}));

const exportEntries = useExportEntries(['npy', 'csv'], dataset, selection, {
csv: () =>
generateCsv(
valueLabel || dataset.name,
isMultiPlot ? getSliceLabel(0) : valueLabel || dataset.name,
dataArray,
abscissaParams,
errorsArray,
Expand All @@ -178,6 +216,8 @@ function MappedLineVis(props: Props) {
<LineVis
className={visualizerStyles.vis}
dataArray={dataArray}
dataLabel={isMultiPlot ? getSliceLabel(0) : undefined}
dataColor={isMultiPlot ? getSliceColor(0) : undefined}
domain={safeDomain}
scaleType={yScaleType}
curveType={curveType}
Expand Down
Loading
Loading