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
4 changes: 2 additions & 2 deletions apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"d3-format": "3.1.2",
"greenlet": "1.1.0",
"modern-normalize": "3.0.1",
"ndarray": "1.0.19",
"ndarray": "1.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-icons": "5.4.0",
Expand All @@ -33,7 +33,7 @@
"@storybook/react-vite": "10.4.6",
"@types/d3-array": "~3.2.2",
"@types/d3-format": "~3.0.4",
"@types/ndarray": "1.0.14",
"@types/ndarray": "1.1.0",
"@types/node": "^24.13.2",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
Expand Down
4 changes: 2 additions & 2 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@
"@react-hookz/web": "25.1.1",
"@react-three/fiber": "8.18.0",
"@types/d3-format": "~3.0.4",
"@types/ndarray": "1.0.14",
"@types/ndarray": "1.1.0",
"d3-format": "3.1.2",
"ndarray": "1.0.19",
"ndarray": "1.1.1",
"ndarray-ops": "1.2.2",
"react-error-boundary": "6.1.2",
"react-icons": "5.4.0",
Expand Down
17 changes: 17 additions & 0 deletions packages/app/src/__tests__/CorePack.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ test('visualize 2D dataset', async () => {
expect(figure.getByLabelText('Max: 4e+2')).toBeVisible();
});

test('visualize 2D float16 dataset', async () => {
// Half-precision values reach the heatmap in a `Float16Array`, which has to
// survive `ndarray` and reach the GPU as a HALF_FLOAT texture.
const { selectVisTab } = await renderApp('/arrays/typed/float16');

expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line, Vis.Heatmap]);
expect(getSelectedVisTab()).toBe(Vis.Heatmap);

const figure = page.getByRole('figure', { name: 'float16' });
expect(figure).toBeVisible();
expect(figure.getByLabelText('Max: 3')).toBeVisible();

await selectVisTab(Vis.Matrix);
expect(page.getByText('1.000e+0').first()).toBeVisible();
expect(page.getByText('3.000e+0').first()).toBeVisible();
});

test('visualize 2D dataset as line', async () => {
const { selectVisTab } = await renderApp('/arrays/twoD');
await selectVisTab(Vis.Line);
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/providers/mock/mock-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function makeMockFile(): GroupWithChildren {
array('uint8', { type: intType(false, 8) }),
array('int16', { type: intType(true, 16) }),
array('int64', { type: intType(true, 64) }),
array('float16', { type: floatType(16) }),
array('float32', { type: floatType(32) }),
array('float64', { type: floatType(64) }),
withImageAttr(array('uint8_rgb', { type: intType(false, 8) })),
Expand Down
4 changes: 2 additions & 2 deletions packages/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
"@types/d3-interpolate": "~3.0.4",
"@types/d3-scale": "~4.0.9",
"@types/d3-scale-chromatic": "~3.1.0",
"@types/ndarray": "~1.0.14",
"@types/ndarray": "~1.1.0",
"@types/react-slider": "~1.3.6",
"@visx/axis": "4.0.0",
"@visx/drag": "4.0.0",
Expand All @@ -76,7 +76,7 @@
"d3-interpolate": "3.0.1",
"d3-scale": "4.0.2",
"d3-scale-chromatic": "3.1.0",
"ndarray": "1.0.19",
"ndarray": "1.1.1",
"ndarray-ops": "1.2.2",
"react-icons": "5.4.0",
"react-is": "^18.3.1",
Expand Down
31 changes: 30 additions & 1 deletion packages/lib/src/vis/heatmap/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { ScaleType } from '@h5web/shared/vis-models';
import { interpolateGreys } from 'd3-scale-chromatic';
import ndarray from 'ndarray';
import { describe, expect, it } from 'vitest';

import { DomainError } from '../models';
import { getLinearGradient, getSafeDomain } from './utils';
import {
getLinearGradient,
getSafeDomain,
toTextureSafeNdArray,
} from './utils';

const white = 'rgb(255, 255, 255)';
const black = 'rgb(0, 0, 0)';
Expand Down Expand Up @@ -73,3 +78,27 @@ describe('getLinearGradient', () => {
expect(colorStops).toBe(`${white}, ${white} 50%, ${black} 50%, ${black}`);
});
});

describe('toTextureSafeNdArray', () => {
it('should pass float16 through as uint16 without copying', () => {
const values = Float16Array.from([1.5, -2.5, 0.5, 3]);
const safe = toTextureSafeNdArray(ndarray(values, [2, 2]));

// `TEXTURE_TYPES` is keyed by dtype, and only "uint16" maps to
// `HalfFloatType`; "float16" would give an undefined texture type.
expect(safe.dtype).toBe('uint16');
// Same memory, so no conversion pass and no second allocation.
expect(safe.data.buffer).toBe(values.buffer);
expect(safe.shape).toEqual([2, 2]);
// The bits are unchanged: reading them back as halves gives the input.
expect([...new Float16Array(safe.data.buffer)]).toEqual([
1.5, -2.5, 0.5, 3,
]);
});

it('should widen other numeric types to float32', () => {
const safe = toTextureSafeNdArray(ndarray(Int16Array.from([1, 2]), [2]));
expect(safe.dtype).toBe('float32');
expect([...safe.data]).toEqual([1, 2]);
});
});
27 changes: 24 additions & 3 deletions packages/lib/src/vis/heatmap/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,15 +153,15 @@ export function scaleDomain(

export function toTextureSafeNdArray(
ndArr: NdArray<NumArray>,
): NdArray<TextureSafeTypedArray>;
): NdArray<TextureSafeTypedArray | Uint16Array>; // uint16 values are treated as half floats

export function toTextureSafeNdArray(
ndArr: NdArray<NumArray> | undefined,
): NdArray<TextureSafeTypedArray> | undefined;
): NdArray<TextureSafeTypedArray | Uint16Array> | undefined;

export function toTextureSafeNdArray(
ndArr: NdArray<NumArray> | undefined,
): NdArray<TextureSafeTypedArray> | undefined {
): NdArray<TextureSafeTypedArray | Uint16Array> | undefined {
if (!ndArr) {
return undefined;
}
Expand All @@ -170,6 +170,27 @@ export function toTextureSafeNdArray(
return ndArr as NdArray<TextureSafeTypedArray>;
}

if (ndArr.dtype === 'float16') {
/* A `Float16Array` already holds the bits a HALF_FLOAT texture wants, so
* view the same buffer as uint16 instead of widening to float32: no copy,
* and half the texture memory. Element width is unchanged, so the stride
* and offset carry over exactly. three's `DataTexture` does not accept a
* `Float16Array` at all, so the view is required rather than merely
* cheaper. (`Float16Array` is not named below: bare, it means
* `Float16Array<ArrayBufferLike>`, which does not satisfy `ndarray`'s
* `Float16Array<ArrayBuffer>` constraint.) */
const { shape, stride, offset } = ndArr;
const { buffer, byteOffset, length } = ndArr.data as ArrayBufferView & {
length: number;
};
return ndarray(
new Uint16Array(buffer, byteOffset, length),
shape,
stride,
offset,
);
}

return toTypedNdArray(ndArr, Float32Array);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@
"@esrf/eslint-config": "3.1.0",
"@types/d3-array": "~3.2.2",
"@types/d3-format": "~3.0.4",
"@types/ndarray": "~1.0.14",
"@types/ndarray": "~1.1.0",
"@types/ndarray-ops": "~1.2.7",
"@types/node": "^24.13.2",
"@types/react": "^18.3.31",
"d3-array": "3.2.4",
"d3-format": "3.1.2",
"eslint": "9.39.4",
"ndarray": "1.0.19",
"ndarray": "1.1.1",
"ndarray-ops": "1.2.2",
"react": "18.3.1",
"typescript": "6.0.3",
Expand Down
16 changes: 15 additions & 1 deletion packages/shared/src/guards.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';

import { assertScalarValue, assertValue } from './guards';
import { assertScalarValue, assertValue, isTypedArray } from './guards';
import {
arrayShape,
arrayType,
Expand Down Expand Up @@ -103,6 +103,20 @@ describe('assertScalarValue', () => {
});
});

describe('isTypedArray', () => {
it('should accept every typed array a dataset value can hold', () => {
expect(isTypedArray(new Float16Array([1.5]))).toBe(true);
expect(isTypedArray(new Float32Array([1.5]))).toBe(true);
expect(isTypedArray(new Uint8Array([1]))).toBe(true);
});

it('should reject bigint typed arrays and non-typed arrays', () => {
expect(isTypedArray(new BigInt64Array([1n]))).toBe(false);
expect(isTypedArray([1.5])).toBe(false);
expect(isTypedArray(undefined)).toBe(false);
});
});

describe('assertValue', () => {
it('should not throw when value satisfies dataset type and shape', () => {
expect(() =>
Expand Down
15 changes: 14 additions & 1 deletion packages/shared/src/guards.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type Data, type NdArray, type TypedArray } from 'ndarray';
import {
type Data,
type MaybeFloat16Array,
type NdArray,
type TypedArray,
} from 'ndarray';

import {
type ArrayShape,
Expand Down Expand Up @@ -169,6 +174,13 @@ export function isComplexArray(val: unknown): val is H5WebComplex[] {
return Array.isArray(val) && isComplex(val[0]);
}

export function isFloat16Array(val: unknown): val is MaybeFloat16Array {
// Unlike the other typed arrays, `Float16Array` is recent enough (Node 24,
// Chrome 135, Firefox 129, Safari 26) that referencing it directly would
// throw a `ReferenceError` in older runtimes.
return 'Float16Array' in globalThis && val instanceof globalThis.Float16Array;
}

export function isTypedArray(val: unknown): val is TypedArray {
return (
val instanceof Int8Array ||
Expand All @@ -178,6 +190,7 @@ export function isTypedArray(val: unknown): val is TypedArray {
val instanceof Uint8ClampedArray ||
val instanceof Uint16Array ||
val instanceof Uint32Array ||
isFloat16Array(val) ||
val instanceof Float32Array ||
val instanceof Float64Array
);
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/mock-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export const mockValues = {
uint8: () => ndarray(Uint8Array.from(range7()), [2, 2]),
int16: () => ndarray(Int16Array.from(range7()), [2, 2]),
int64: () => ndarray(BigInt64Array.from(range7(), BigInt), [2, 2]),
float16: () => ndarray(Float16Array.from(range7()), [2, 2]),
float32: () => ndarray(Float32Array.from(range7()), [2, 2]),
float64: () => ndarray(Float64Array.from(range7()), [2, 2]),
int8_rgb: () =>
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/vis-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import ndarray from 'ndarray';
import { describe, expect, it } from 'vitest';

import { getBounds, toTypedNdArray } from './vis-utils';

function values() {
return ndarray(Float16Array.from([0, 1.5, -2.5, 3]), [2, 2]);
}

// `ndarray` dispatches on the array's type to decide between indexing and
// getters; an unrecognised typed array falls back to "generic", whose getters a
// typed array does not have, so `.get()` throws. Half-precision datasets
// therefore only work once `ndarray` knows the type.
describe('float16 values', () => {
it('should be indexable through ndarray', () => {
const arr = values();
expect(arr.dtype).toBe('float16');
expect(arr.get(0, 1)).toBe(1.5);
expect(arr.get(1, 0)).toBe(-2.5);
});

it('should have computable bounds', () => {
expect(getBounds(values())).toEqual({
min: -2.5,
max: 3,
positiveMin: 0, // zero is in the data; `strictPositiveMin` is the `> 0` one
strictPositiveMin: 1.5,
});
});

it('should convert losslessly to a texture-safe array', () => {
// Every half is exactly representable in float32, so widening for the GPU
// must not perturb the values.
const converted = toTypedNdArray(values(), Float32Array);
expect(converted.dtype).toBe('float32');
expect([...converted.data]).toEqual([0, 1.5, -2.5, 3]);
});
});