diff --git a/cypress/snapshots/app.cy.ts/bgr_image.snap.png b/cypress/snapshots/app.cy.ts/bgr_image.snap.png index 54fda0776..c6e01ef96 100644 Binary files a/cypress/snapshots/app.cy.ts/bgr_image.snap.png and b/cypress/snapshots/app.cy.ts/bgr_image.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/compound_1D.snap.png b/cypress/snapshots/app.cy.ts/compound_1D.snap.png index 3686f07b4..59c89a5dd 100644 Binary files a/cypress/snapshots/app.cy.ts/compound_1D.snap.png and b/cypress/snapshots/app.cy.ts/compound_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png index 8f9e45f34..7a55b6591 100644 Binary files a/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png and b/cypress/snapshots/app.cy.ts/fillvalue_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png b/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png index f58f5ce09..3fa6b56da 100644 Binary files a/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png and b/cypress/snapshots/app.cy.ts/fillvalue_2D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png index 947412e67..fe4099dac 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png index 1d3ef1c21..6e08ba18c 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D_complex.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png index 9dd945b56..4c113eb52 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png index 2ef4e1f6c..3030d209b 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_default.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png index 4e8a97ae2..89be9dc3f 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_remapped.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png index ab69ac9f9..b6df2eb5c 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_sliced.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png b/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png index a49491608..06e17e583 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_4d_zeros.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png b/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png index 500e622cd..bd9e894f8 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_domain.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_1D.snap.png b/cypress/snapshots/app.cy.ts/line_1D.snap.png index 18bb291d5..7068d8609 100644 Binary files a/cypress/snapshots/app.cy.ts/line_1D.snap.png and b/cypress/snapshots/app.cy.ts/line_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png b/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png index 85920e92c..5f502a777 100644 Binary files a/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png and b/cypress/snapshots/app.cy.ts/line_complex_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/matrix_1D.snap.png b/cypress/snapshots/app.cy.ts/matrix_1D.snap.png index 8aa809c41..79882d22b 100644 Binary files a/cypress/snapshots/app.cy.ts/matrix_1D.snap.png and b/cypress/snapshots/app.cy.ts/matrix_1D.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/rgb_image.snap.png b/cypress/snapshots/app.cy.ts/rgb_image.snap.png index 5959888bd..a76a6ea44 100644 Binary files a/cypress/snapshots/app.cy.ts/rgb_image.snap.png and b/cypress/snapshots/app.cy.ts/rgb_image.snap.png differ diff --git a/packages/app/src/__tests__/CorePack.test.tsx b/packages/app/src/__tests__/CorePack.test.tsx index d4fca6350..8169bbd90 100644 --- a/packages/app/src/__tests__/CorePack.test.tsx +++ b/packages/app/src/__tests__/CorePack.test.tsx @@ -96,6 +96,14 @@ test('visualize 1D compound dataset', async () => { expect(screen.getByText('Argon')).toBeVisible(); }); +test('visualize 1D opaque dataset', async () => { + await renderApp('/nD_datasets/oneD_opaque'); + + expect(getVisTabs()).toEqual([Vis.Array]); + expect(getSelectedVisTab()).toBe(Vis.Array); + expect(screen.getByText('Uint8Array [ 0,1,2 ]')).toBeVisible(); +}); + test('visualize 2D dataset', async () => { await renderApp('/nD_datasets/twoD'); @@ -166,6 +174,14 @@ test('visualize 2D complex dataset', async () => { ).toBeVisible(); }); +test('visualize 2D opaque dataset', async () => { + await renderApp('/nD_datasets/twoD_opaque'); + + expect(getVisTabs()).toEqual([Vis.Array]); + expect(getSelectedVisTab()).toBe(Vis.Array); + expect(screen.getByText('Uint8Array [ 0,1 ]')).toBeVisible(); +}); + test('show interactions help for heatmap according to "keep ratio"', async () => { const { user } = await renderApp(); diff --git a/packages/app/src/__tests__/DimensionMapper.test.tsx b/packages/app/src/__tests__/DimensionMapper.test.tsx index d1dcc6d1c..9226a58b1 100644 --- a/packages/app/src/__tests__/DimensionMapper.test.tsx +++ b/packages/app/src/__tests__/DimensionMapper.test.tsx @@ -109,6 +109,19 @@ test('slice through 2D dataset', async () => { expect(d0Slider).toHaveValue(1); }); +test('slice through 2D opaque dataset', async () => { + const { user } = await renderApp('/nD_datasets/twoD_opaque'); + + expect(screen.getByText('Uint8Array [ 0,1 ]')).toBeVisible(); + + // Move to next slice with keyboard + const d0Slider = screen.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{ArrowUp}'); + + expect(d0Slider).toHaveValue(1); + await expect(screen.findByText('Uint8Array [ 4,5 ]')).resolves.toBeVisible(); +}); + test('maintain mapping when switching to inspect mode and back', async () => { const { user } = await renderApp({ initialPath: '/nD_datasets/twoD', diff --git a/packages/app/src/dimension-mapper/DimensionMapper.module.css b/packages/app/src/dimension-mapper/DimensionMapper.module.css index b5d2c81eb..864aa222e 100644 --- a/packages/app/src/dimension-mapper/DimensionMapper.module.css +++ b/packages/app/src/dimension-mapper/DimensionMapper.module.css @@ -43,6 +43,7 @@ .dimSize { flex: 1 0; + min-width: 2.25em; /* in case there are no axes */ padding: 0 0.1875rem; text-align: center; } diff --git a/packages/app/src/dimension-mapper/store.tsx b/packages/app/src/dimension-mapper/store.tsx index daf66bac9..c647a1ad5 100644 --- a/packages/app/src/dimension-mapper/store.tsx +++ b/packages/app/src/dimension-mapper/store.tsx @@ -23,7 +23,7 @@ interface DimMappingState { ) => void; } -function createLineConfigStore() { +function createDimMappingStore() { return createStore((set) => ({ dims: [], axesCount: 0, @@ -41,7 +41,7 @@ interface Props {} export function DimMappingProvider(props: PropsWithChildren) { const { children } = props; - const [store] = useState(createLineConfigStore); + const [store] = useState(createDimMappingStore); return ( {children} @@ -62,7 +62,7 @@ export function useDimMappingState( const mapping = isStale ? [ ...Array.from({ length: dims.length - axesCount }, () => 0), - ...(dims.length > 0 + ...(dims.length > 0 && axesCount > 0 ? ['y' as const, 'x' as const].slice(-axesCount) : []), ] diff --git a/packages/app/src/hooks.ts b/packages/app/src/hooks.ts index 0c0651151..0f2c165e2 100644 --- a/packages/app/src/hooks.ts +++ b/packages/app/src/hooks.ts @@ -47,7 +47,7 @@ export function useDatasetValue>( // If `selection` is undefined, the entire dataset will be fetched const value = valuesStore.get({ dataset, selection }); - assertDatasetValue(value, dataset); + assertDatasetValue(value, dataset, selection); return value; } @@ -73,7 +73,7 @@ export function useDatasetsValues>( } const value = valuesStore.get({ dataset, selection }); - assertDatasetValue(value, dataset); + assertDatasetValue(value, dataset, selection); return value; }); } diff --git a/packages/app/src/providers/h5grove/__snapshots__/h5grove-api.test.ts.snap b/packages/app/src/providers/h5grove/__snapshots__/h5grove-api.test.ts.snap index 32cb4906d..7ebf1bf68 100644 --- a/packages/app/src/providers/h5grove/__snapshots__/h5grove-api.test.ts.snap +++ b/packages/app/src/providers/h5grove/__snapshots__/h5grove-api.test.ts.snap @@ -852,6 +852,29 @@ exports[`test file matches snapshot 1`] = ` 34, ], }, + { + "name": "byte_string_2D", + "rawType": { + "class": 5, + "dtype": "|V1", + "size": 1, + "tag": "", + }, + "shape": [ + 2, + 2, + ], + "type": { + "class": "Opaque", + "tag": "", + }, + "value": Uint8Array [ + 0, + 17, + 0, + 17, + ], + }, { "name": "datetime64_scalar", "rawType": { @@ -1586,6 +1609,108 @@ exports[`test file matches snapshot 1`] = ` ], ], }, + { + "name": "compound_mixed_2D", + "rawType": { + "class": 6, + "dtype": { + "arr": "|V8", + "bool": "|b1", + }, + "members": { + "arr": { + "base": { + "class": 1, + "dtype": " { - const { dataset } = params; + const { dataset, selection } = params; assertHsdsDataset(dataset); const value = await this.fetchValue(dataset.id, params, signal, onProgress); - /* HSDS doesn't reduce the number of dimensions when selecting indices, - * so the flattening must be done on all dimensions regardless of the selection. - * https://github.com/HDFGroup/hsds/issues/88 */ - return hasArrayShape(dataset) ? flattenValue(value, dataset) : value; + // HSDS doesn't reduce the number of dimensions when slicing + return hasArrayShape(dataset) + ? flattenValue(value, dataset, selection) + : value; } public override async getAttrValues( diff --git a/packages/app/src/providers/hsds/utils.ts b/packages/app/src/providers/hsds/utils.ts index 4ab1f8fb5..9e8e597ff 100644 --- a/packages/app/src/providers/hsds/utils.ts +++ b/packages/app/src/providers/hsds/utils.ts @@ -1,4 +1,4 @@ -import { assertArray, isGroup } from '@h5web/shared/guards'; +import { assertArray, isGroup, isScalarSelection } from '@h5web/shared/guards'; import { H5T_CSET, H5T_ORDER, H5T_STR } from '@h5web/shared/h5t'; import { type ArrayShape, @@ -166,9 +166,13 @@ export function flattenValue( value: unknown, dataset: Dataset, selection?: string, -): unknown[] { +): unknown { assertArray(value); - const slicedDims = selection?.split(',').filter((s) => s.includes(':')); - const dims = slicedDims || dataset.shape; - return value.flat(dims.length - 1); + + // Always flatten on all dimensions, regardless of selection + // https://github.com/HDFGroup/hsds/issues/88 + const flattened = value.flat(dataset.shape.length - 1); + + // Remove last remaining dimension when selecting a scalar value + return isScalarSelection(selection) ? flattened[0] : flattened; } diff --git a/packages/app/src/providers/mock/mock-api.ts b/packages/app/src/providers/mock/mock-api.ts index 43d40c83d..23eaffbc0 100644 --- a/packages/app/src/providers/mock/mock-api.ts +++ b/packages/app/src/providers/mock/mock-api.ts @@ -1,4 +1,8 @@ -import { assertArrayShape, assertDefined } from '@h5web/shared/guards'; +import { + assertArray, + assertArrayShape, + assertDefined, +} from '@h5web/shared/guards'; import { type ArrayShape, type AttributeValues, @@ -79,6 +83,8 @@ export class MockApi extends DataProviderApi { } assertArrayShape(dataset); + assertArray(value); + return sliceValue(value, dataset, selection); } diff --git a/packages/app/src/providers/mock/mock-file.ts b/packages/app/src/providers/mock/mock-file.ts index 0ef49c1e8..d3d699a2c 100644 --- a/packages/app/src/providers/mock/mock-file.ts +++ b/packages/app/src/providers/mock/mock-file.ts @@ -132,6 +132,13 @@ export function makeMockFile(): GroupWithChildren { array('oneD_enum', { type: enumType(intType(false, 8), ENUM_MAPPING), }), + array('oneD_opaque', { type: opaqueType() }), + dataset( + 'oneD_opaque_png', + opaqueType(), + [2], + [PNG_RED_DOT, PNG_RED_DOT], + ), array('twoD'), array('twoD_fillvalue', { valueId: 'twoD', @@ -152,6 +159,7 @@ export function makeMockFile(): GroupWithChildren { array('twoD_enum', { type: enumType(intType(false, 8), ENUM_MAPPING), }), + array('twoD_opaque', { type: opaqueType() }), array('threeD'), array('threeD_bool'), array('threeD_cplx'), diff --git a/packages/app/src/providers/mock/utils.ts b/packages/app/src/providers/mock/utils.ts index 60f104c83..cff695ebd 100644 --- a/packages/app/src/providers/mock/utils.ts +++ b/packages/app/src/providers/mock/utils.ts @@ -3,21 +3,18 @@ import { assertGroup, assertGroupWithChildren, isGroup, + isScalarSelection, } from '@h5web/shared/guards'; import { type ArrayShape, type Dataset, - type DType, type GroupWithChildren, type ProvidedEntity, - type ScalarShape, - type ScalarValue, } from '@h5web/shared/hdf5-models'; import { getChildEntity } from '@h5web/shared/hdf5-utils'; +import { createArrayFromView } from '@h5web/shared/vis-utils'; import ndarray from 'ndarray'; -import { applyMapping } from '../../vis-packs/core/utils'; - export const SLOW_TIMEOUT = 3000; export function findMockEntity( @@ -46,19 +43,22 @@ export function findMockEntity( return child; } -export function sliceValue( - value: unknown, - dataset: Dataset, +export function sliceValue( + value: unknown[], + dataset: Dataset, selection: string, -): ScalarValue[] { +): unknown { const { shape } = dataset; - const dataArray = ndarray(value as ScalarValue[], shape); - const mappedArray = applyMapping( - dataArray, - selection.split(',').map((s) => (s === ':' ? s : Number.parseInt(s))), - ); + const dataArray = ndarray(value, shape); + + const slicingState = selection + .split(',') + .map((val) => (val === ':' ? null : Number.parseInt(val))); + + const slicedView = dataArray.pick(...slicingState); + const slicedArray = createArrayFromView(slicedView); - return mappedArray.data; + return isScalarSelection(selection) ? slicedArray.get(0) : slicedArray.data; } export function getChildrenPaths( diff --git a/packages/app/src/vis-packs/core/array/ArrayVisContainer.tsx b/packages/app/src/vis-packs/core/array/ArrayVisContainer.tsx new file mode 100644 index 000000000..a29259603 --- /dev/null +++ b/packages/app/src/vis-packs/core/array/ArrayVisContainer.tsx @@ -0,0 +1,50 @@ +import { assertArrayShape, assertDataset } from '@h5web/shared/guards'; + +import DimensionMapper from '../../../dimension-mapper/DimensionMapper'; +import { useValuesInCache } from '../../../dimension-mapper/hooks'; +import { useDimMappingState } from '../../../dimension-mapper/store'; +import { type VisContainerProps } from '../../models'; +import VisBoundary from '../../VisBoundary'; +import { useRawConfig } from '../raw/config'; +import MappedRawVis from '../raw/MappedRawVis'; +import { getSliceSelection } from '../utils'; +import ValueFetcher from '../ValueFetcher'; + +function ArrayVisContainer(props: VisContainerProps) { + const { entity, toolbarContainer } = props; + assertDataset(entity); + assertArrayShape(entity); + + const { shape: dims } = entity; + const [dimMapping, setDimMapping] = useDimMappingState(dims, 0); // no axes, slicing only + + const config = useRawConfig(); + const selection = getSliceSelection(dimMapping); + + return ( + <> + + + ( + + )} + /> + + + ); +} + +export default ArrayVisContainer; diff --git a/packages/app/src/vis-packs/core/containers.ts b/packages/app/src/vis-packs/core/containers.ts index 856beb608..27aa03d38 100644 --- a/packages/app/src/vis-packs/core/containers.ts +++ b/packages/app/src/vis-packs/core/containers.ts @@ -1,5 +1,6 @@ export { default as RawVisContainer } from './raw/RawVisContainer'; export { default as ScalarVisContainer } from './scalar/ScalarVisContainer'; +export { default as ArrayVisContainer } from './array/ArrayVisContainer'; export { default as MatrixVisContainer } from './matrix/MatrixVisContainer'; export { default as LineVisContainer } from './line/LineVisContainer'; export { default as HeatmapVisContainer } from './heatmap/HeatmapVisContainer'; diff --git a/packages/app/src/vis-packs/core/visualizations.test.ts b/packages/app/src/vis-packs/core/visualizations.test.ts index be73897ea..ba1d4f678 100644 --- a/packages/app/src/vis-packs/core/visualizations.test.ts +++ b/packages/app/src/vis-packs/core/visualizations.test.ts @@ -5,6 +5,7 @@ import { cplxType, floatType, intType, + opaqueType, strType, } from '@h5web/shared/hdf5-utils'; import { @@ -29,6 +30,7 @@ const mockStore = { }, }; +const nullShape = dataset('null', intType(), null); const scalarInt = dataset('int', intType(), []); const scalarUint = dataset('uint', intType(false), []); const scalarBigInt = dataset('bigint', intType(true, 64), []); @@ -37,12 +39,14 @@ const scalarStr = dataset('float', strType(), []); const scalarBool = dataset('bool', boolType(intType(true, 8)), []); const scalarCplx = dataset('cplx', cplxType(floatType()), []); const scalarCompound = dataset('comp', compoundType({ int: intType() }), []); +const scalarOpaque = dataset('opaque', opaqueType(), []); const oneDInt = dataset('int_1d', intType(), [5]); const oneDUint = dataset('uint_1d', intType(false), [5]); const oneDBigUint = dataset('biguint_1d', intType(false, 64), [5]); const oneDBool = dataset('bool_1d', boolType(intType(true, 8)), [3]); const oneDCplx = dataset('cplx_1d', cplxType(floatType()), [10]); const oneDCompound = dataset('comp_1d', compoundType({ int: intType() }), [5]); +const oneDOpaque = dataset('opaque_1d', opaqueType(), [5]); const twoDInt = dataset('int_2d', intType(), [5, 3]); const twoDUint = dataset('uint_2d', intType(false), [5, 3]); const twoDBool = dataset('bool_2d', boolType(intType(true, 8)), [3, 2]); @@ -72,9 +76,14 @@ const nestedCompound = dataset( describe('Raw', () => { const { supportsDataset } = CORE_VIS.Raw; - it('should support any dataset', () => { + it('should support any scalar dataset', () => { expect(supportsDataset(scalarInt)).toBe(true); - expect(supportsDataset(twoDStr)).toBe(true); + expect(supportsDataset(scalarOpaque)).toBe(true); + }); + + it('should not support dataset with non-scalar shape', () => { + expect(supportsDataset(nullShape)).toBe(false); + expect(supportsDataset(oneDInt)).toBe(false); }); }); @@ -100,6 +109,21 @@ describe('Scalar', () => { }); }); +describe('Array', () => { + const { supportsDataset } = CORE_VIS.Array; + + it('should support any array dataset', () => { + expect(supportsDataset(oneDBigUint)).toBe(true); + expect(supportsDataset(oneDOpaque)).toBe(true); + }); + + it('should not support dataset with non-array shape', () => { + expect(supportsDataset(nullShape)).toBe(false); + expect(supportsDataset(scalarUint)).toBe(false); + expect(supportsDataset(scalarOpaque)).toBe(false); + }); +}); + describe('Matrix', () => { const { supportsDataset } = CORE_VIS.Matrix; diff --git a/packages/app/src/vis-packs/core/visualizations.ts b/packages/app/src/vis-packs/core/visualizations.ts index e3dc1feb7..8171dd959 100644 --- a/packages/app/src/vis-packs/core/visualizations.ts +++ b/packages/app/src/vis-packs/core/visualizations.ts @@ -20,6 +20,7 @@ import { FiMap, FiPackage, } from 'react-icons/fi'; +import { MdDataArray } from 'react-icons/md'; import { type AttrValuesStore } from '../../providers/models'; import { type VisDef } from '../models'; @@ -33,6 +34,7 @@ import { RgbConfigProvider, } from './configs'; import { + ArrayVisContainer, ComplexLineVisContainer, ComplexVisContainer, CompoundVisContainer, @@ -49,6 +51,7 @@ import SurfaceVisContainer from './surface/SurfaceVisContainer'; export enum Vis { Raw = 'Raw', Scalar = 'Scalar', + Array = 'Array', Matrix = 'Matrix', Line = 'Line', Heatmap = 'Heatmap', @@ -72,7 +75,7 @@ export const CORE_VIS = { Icon: FiCpu, Container: RawVisContainer, ConfigProvider: RawConfigProvider, - supportsDataset: hasNonNullShape, + supportsDataset: hasScalarShape, }, [Vis.Scalar]: { @@ -84,6 +87,14 @@ export const CORE_VIS = { }, }, + [Vis.Array]: { + name: Vis.Array, + Icon: MdDataArray, + Container: ArrayVisContainer, + ConfigProvider: RawConfigProvider, + supportsDataset: hasArrayShape, + }, + [Vis.Matrix]: { name: Vis.Matrix, Icon: FiGrid, @@ -167,8 +178,7 @@ export const CORE_VIS = { return ( hasCompoundType(dataset) && hasPrintableCompoundType(dataset) && - hasNonNullShape(dataset) && - (hasScalarShape(dataset) || hasMinDims(dataset, 1)) + hasNonNullShape(dataset) ); }, }, diff --git a/packages/app/src/visualizer/utils.ts b/packages/app/src/visualizer/utils.ts index 194f4d07c..e0bd22d63 100644 --- a/packages/app/src/visualizer/utils.ts +++ b/packages/app/src/visualizer/utils.ts @@ -82,6 +82,8 @@ function getNxDefaultPath( return getImplicitDefaultChild(entity.children, attrValueStore)?.path; } +const FALLBACK_VIS = new Set([Vis.Raw, Vis.Array]); + function getSupportedCoreVis( entity: ProvidedEntity, attrValueStore: AttrValuesStore, @@ -91,7 +93,7 @@ function getSupportedCoreVis( ); return supportedVis.length > 1 - ? supportedVis.filter((vis) => vis.name !== Vis.Raw) + ? supportedVis.filter((vis) => !FALLBACK_VIS.has(vis.name)) : supportedVis; } diff --git a/packages/h5wasm/src/__snapshots__/h5wasm-api.test.ts.snap b/packages/h5wasm/src/__snapshots__/h5wasm-api.test.ts.snap index c684584da..c3999377e 100644 --- a/packages/h5wasm/src/__snapshots__/h5wasm-api.test.ts.snap +++ b/packages/h5wasm/src/__snapshots__/h5wasm-api.test.ts.snap @@ -903,6 +903,31 @@ exports[`test file matches snapshot 1`] = ` 34, ], }, + { + "name": "byte_string_2D", + "rawType": { + "littleEndian": false, + "signed": false, + "size": 1, + "total_size": 4, + "type": 5, + "vlen": false, + }, + "shape": [ + 2, + 2, + ], + "type": { + "class": "Opaque", + "tag": "", + }, + "value": Uint8Array [ + 0, + 17, + 0, + 17, + ], + }, { "name": "datetime64_scalar", "rawType": { @@ -1785,6 +1810,120 @@ exports[`test file matches snapshot 1`] = ` ], ], }, + { + "name": "compound_mixed_2D", + "rawType": { + "compound_type": { + "members": [ + { + "enum_type": { + "members": { + "FALSE": 0, + "TRUE": 1, + }, + "nmembers": 2, + "type": 0, + }, + "littleEndian": true, + "name": "bool", + "offset": 0, + "shape": [], + "signed": true, + "size": 1, + "type": 8, + "vlen": false, + }, + { + "array_type": { + "littleEndian": true, + "shape": [ + 2, + ], + "signed": false, + "size": 4, + "total_size": 2, + "type": 1, + "vlen": false, + }, + "littleEndian": true, + "name": "arr", + "offset": 1, + "shape": [], + "signed": false, + "size": 8, + "type": 10, + "vlen": false, + }, + ], + "nmembers": 2, + }, + "littleEndian": true, + "signed": false, + "size": 9, + "total_size": 4, + "type": 6, + "vlen": false, + }, + "shape": [ + 2, + 2, + ], + "type": { + "class": "Compound", + "fields": { + "arr": { + "base": { + "class": "Float", + "endianness": "little-endian", + "size": 32, + }, + "class": "Array", + "dims": [ + 2, + ], + }, + "bool": { + "base": { + "class": "Integer", + "endianness": "little-endian", + "signed": true, + "size": 8, + }, + "class": "Boolean", + }, + }, + }, + "value": [ + [ + 1, + [ + 0, + 1, + ], + ], + [ + 0, + [ + 2, + 3, + ], + ], + [ + 0, + [ + 4, + 5, + ], + ], + [ + 1, + [ + 6, + 7, + ], + ], + ], + }, { "name": "reference_scalar", "rawType": { diff --git a/packages/h5wasm/src/worker-utils.ts b/packages/h5wasm/src/worker-utils.ts index f9d63c8ad..132956ebb 100644 --- a/packages/h5wasm/src/worker-utils.ts +++ b/packages/h5wasm/src/worker-utils.ts @@ -2,6 +2,7 @@ import { assertDefined, assertNonNull, isNumericType, + isScalarSelection, } from '@h5web/shared/guards'; import { H5T_CLASS, H5T_ORDER, type H5T_STR } from '@h5web/shared/h5t'; import { @@ -321,5 +322,14 @@ export function readSelectedValue( return [Number(member), Number(member) + 1]; }); - return h5wDataset.slice(ranges); + const slicedValue = h5wDataset.slice(ranges); + assertNonNull(slicedValue); + + /* h5wasm unwraps scalar slices inconsistently - e.g. it does so for opaque + * datasets but not for compound datasets */ + if (isScalarSelection(selection) && Array.isArray(slicedValue)) { + return slicedValue[0]; + } + + return slicedValue; } diff --git a/packages/shared/src/guards.test.ts b/packages/shared/src/guards.test.ts index dc99a64fd..5553154d7 100644 --- a/packages/shared/src/guards.test.ts +++ b/packages/shared/src/guards.test.ts @@ -133,27 +133,100 @@ describe('assertDatasetValue', () => { dataset('foo', intType(true, 64), [2]), ), ).not.toThrow(); + + expect(() => + assertDatasetValue( + Float32Array.from([0, 1]), // big ints can be returned as any kind of numbers + dataset('foo', intType(true, 64), [2]), + ), + ).not.toThrow(); }); - describe('assertDatasetValue', () => { - it("should throw when value doesn't satisfy dataset type and shape", () => { - expect(() => - assertDatasetValue( - true, - dataset('foo', enumType(intType(), { FOO: 0 }), []), - ), - ).toThrow('Expected number'); - - expect(() => - assertDatasetValue(['foo', 'bar'], dataset('foo', intType(), [2])), - ).toThrow('Expected number'); - - expect(() => - assertDatasetValue( - BigInt64Array.from([0n, 1n]), - dataset('foo', intType(), [2]), - ), - ).toThrow('Expected number'); - }); + it("should throw when value doesn't satisfy dataset type and shape", () => { + expect(() => + assertDatasetValue( + true, + dataset('foo', enumType(intType(), { FOO: 0 }), []), + ), + ).toThrow('Expected number'); + + expect(() => + assertDatasetValue(['foo', 'bar'], dataset('foo', intType(), [2])), + ).toThrow('Expected number'); + + expect(() => + assertDatasetValue( + BigInt64Array.from([0n, 1n]), + dataset('foo', intType(), [2]), + ), + ).toThrow('Expected number'); + }); + + it('should not throw when value shape satisfies selection', () => { + expect(() => + assertDatasetValue( + 0, // scalar => OK + dataset('foo', intType(), [1]), // 1D dataset + '0', // scalar selection (only in "Array" vis) + ), + ).not.toThrow(); + + expect(() => + assertDatasetValue( + [0], // array => OK + dataset('foo', intType(), [1]), // 1D dataset + ':', // entire array + ), + ).not.toThrow(); + + expect(() => + assertDatasetValue( + 0, // scalar => OK + dataset('foo', intType(), [1, 1]), // 2D dataset + '0,0', // scalar selection (only in "Array" vis) + ), + ).not.toThrow(); + + expect(() => + assertDatasetValue( + [0], // array => OK + dataset('foo', intType(), [1, 1]), // 2D dataset + '0,:', // 1D slice selection + ), + ).not.toThrow(); + }); + + it("should throw when value shape doesn't satisfy selection", () => { + expect(() => + assertDatasetValue( + [0], // array => NOT OK + dataset('foo', intType(), [1]), // 1D dataset + '0', // scalar selection (only in "Array" vis) + ), + ).toThrow('Expected number'); + + expect(() => + assertDatasetValue( + 0, // scalar => NOT OK + dataset('foo', intType(), [1]), // 1D dataset + ':', // entire array + ), + ).toThrow('Expected array or typed array'); + + expect(() => + assertDatasetValue( + [0], // array => NOT OK + dataset('foo', intType(), [1, 1]), // 2D dataset + '0,0', // scalar selection (only in "Array" vis) + ), + ).toThrow('Expected number'); + + expect(() => + assertDatasetValue( + 0, // scalar => NOT OK + dataset('foo', intType(), [1, 1]), // 2D dataset + '0,:', // 1D slice + ), + ).toThrow('Expected array'); }); }); diff --git a/packages/shared/src/guards.ts b/packages/shared/src/guards.ts index 1bceea6cb..786a619a8 100644 --- a/packages/shared/src/guards.ts +++ b/packages/shared/src/guards.ts @@ -2,6 +2,7 @@ import { type Data, type NdArray, type TypedArray } from 'ndarray'; import { type ArrayShape, + type ArrayType, type BooleanType, type ComplexArray, type ComplexType, @@ -26,6 +27,7 @@ import { type Shape, type StringType, type Value, + type VLenType, } from './hdf5-models'; import { type AnyNumArray, @@ -523,6 +525,14 @@ export function isComplexValue( return type.class === DTypeClass.Complex; } +export function isArrayOrVlenType(type: DType): type is ArrayType | VLenType { + return type.class === DTypeClass.Array || type.class === DTypeClass.VLen; +} + +export function isScalarSelection(selection: string | undefined): boolean { + return selection !== undefined && /^\d+(?:,\d+)*$/u.test(selection); +} + export function assertScalarValue( value: unknown, type: DType, @@ -544,16 +554,22 @@ export function assertScalarValue( Object.values(type.fields).forEach((fieldType, index) => { assertScalarValue(value[index], fieldType); }); + } else if (isArrayOrVlenType(type)) { + assertArrayOrTypedArray(value); + if (value.length > 0) { + assertScalarValue(value[0], type.base); + } } } export function assertDatasetValue>( value: unknown, dataset: D, + selection?: string, ): asserts value is Value { const { type } = dataset; - if (hasScalarShape(dataset)) { + if (hasScalarShape(dataset) || isScalarSelection(selection)) { assertScalarValue(value, type); } else { assertArrayOrTypedArray(value); diff --git a/packages/shared/src/mock-values.ts b/packages/shared/src/mock-values.ts index 50308b221..b0f7a98d9 100644 --- a/packages/shared/src/mock-values.ts +++ b/packages/shared/src/mock-values.ts @@ -121,6 +121,8 @@ export const mockValues = { oneD_enum, oneD_errors: () => ndarray(oneD().data.map((val) => Math.abs(val) / 10)), oneD_str: () => ndarray(['foo', 'bar']), + oneD_opaque: () => + ndarray([new Uint8Array([0, 1, 2]), new Uint8Array([3, 4, 5])]), twoD, twoD_asym: () => { const { data: dataTwoD, shape: shapeTwoD } = twoD(); @@ -174,6 +176,16 @@ export const mockValues = { [20, 41], ); }, + twoD_opaque: () => + ndarray( + [ + new Uint8Array([0, 1]), + new Uint8Array([2, 3]), + new Uint8Array([4, 5]), + new Uint8Array([6, 7]), + ], + [2, 2], + ), threeD, threeD_cplx: () => ndarray( diff --git a/support/sample/create_h5_sample.py b/support/sample/create_h5_sample.py index 4abdcd251..9e5fe8fcc 100644 --- a/support/sample/create_h5_sample.py +++ b/support/sample/create_h5_sample.py @@ -122,6 +122,13 @@ def print_h5t_class(dataset): "byte_string", np.array([np.void(b"\x00"), np.void(b"\x11"), np.void(b"\x22")]), ) + add_array( + h5, + "byte_string", + np.array( + [[np.void(b"\x00"), np.void(b"\x11")], [np.void(b"\x00"), np.void(b"\x11")]] + ), + ) add_scalar(h5, "datetime64", np.void(np.datetime64("2019-09-22T17:38:30"))) add_scalar(h5, "datetime64_not-a-time", np.void(np.datetime64("NaT"))) @@ -215,6 +222,36 @@ def print_h5t_class(dataset): ), ) + add_array( + h5, + "compound_mixed", + np.array( + [ + [ + ( + True, + np.array([0, 1], np.float32), + ), + ( + False, + np.array([2, 3], np.float32), + ), + ], + [ + ( + False, + np.array([4, 5], np.float32), + ), + ( + True, + np.array([6, 7], np.float32), + ), + ], + ], + [("bool", np.bool_), ("arr", np.float32, (2,))], + ), + ) + # === H5T_REFERENCE === add_scalar(h5, "reference", for_ref.ref, h5py.ref_dtype) @@ -282,9 +319,9 @@ def print_h5t_class(dataset): vlen_array = add_array( h5, "vlen_utf8", shape=(3,), dtype=h5py.vlen_dtype(h5py.string_dtype()) ) - vlen_array[0] = ['a'] - vlen_array[1] = ['a', 'bc'] - vlen_array[2] = ['a', 'bc', 'def'] + vlen_array[0] = ["a"] + vlen_array[1] = ["a", "bc"] + vlen_array[2] = ["a", "bc", "def"] # === H5T_ARRAY ===