diff --git a/apps/demo/src/DemoApp.tsx b/apps/demo/src/DemoApp.tsx index 3a0840423..e48165bc0 100644 --- a/apps/demo/src/DemoApp.tsx +++ b/apps/demo/src/DemoApp.tsx @@ -9,10 +9,6 @@ import MockApp from './MockApp'; // Split H5Wasm demo into its own bundle, and load it only when the demo is first visited const H5WasmApp = lazy(async () => import('./h5wasm/H5WasmApp')); -const query = new URLSearchParams(document.location.search); -// @ts-expect-error - Untyped global flag -globalThis.H5WEB_EXPERIMENTAL = query.has('experimental'); - function DemoApp() { return ( diff --git a/apps/storybook/src/SurfaceVis.stories.tsx b/apps/storybook/src/SurfaceVis.stories.tsx index 12ad669a6..9033ef207 100644 --- a/apps/storybook/src/SurfaceVis.stories.tsx +++ b/apps/storybook/src/SurfaceVis.stories.tsx @@ -1,41 +1,95 @@ import { getDomain, mockValues, SurfaceVis } from '@h5web/lib'; import { assertDefined } from '@h5web/shared/guards'; +import { COLOR_SCALE_TYPES, ScaleType } from '@h5web/shared/vis-models'; import { createArrayFromView } from '@h5web/shared/vis-utils'; -import { extend, useThree } from '@react-three/fiber'; -import { OrbitControls as ThreeOrbitControls } from 'three/addons/controls/OrbitControls.js'; +import ndarray from 'ndarray'; import preview from '../.storybook/preview'; import FillHeight from './decorators/FillHeight'; -const OrbitControls = extend(ThreeOrbitControls); - const dataArray = createArrayFromView(mockValues.fourD().pick(0, 0)); const domain = getDomain(dataArray.data); assertDefined(domain); +const [rows, cols] = dataArray.shape; +const abscissas = Float32Array.from({ length: cols }, (_, i) => -2 + i * 0.1); +const ordinates = Float32Array.from({ length: rows }, (_, i) => i ** 1.5); + +const withHoles = ndarray( + Float32Array.from(dataArray.data, (val, i) => + i % cols > cols / 3 && i % cols < cols / 2 ? Number.NaN : val, + ), + dataArray.shape, +); + const meta = preview.meta({ - title: 'Experimental/SurfaceVis', + title: 'Visualizations/SurfaceVis', component: SurfaceVis, decorators: [FillHeight], + args: { + dataArray, + domain, + }, argTypes: { dataArray: { control: false }, + scaleType: { + control: { type: 'inline-radio' }, + options: COLOR_SCALE_TYPES, + }, + heightScale: { + control: { type: 'range', min: 0.05, max: 2, step: 0.05 }, + }, }, }); -export const Default = meta.story({ - render: (args) => ( - - - - ), +export const Default = meta.story(); + +export const AxisValues = meta.story({ args: { - dataArray, - domain, + title: 'With axis values', + abscissaParams: { value: abscissas }, + ordinateParams: { value: ordinates }, }, }); -function Controls() { - const camera = useThree((state) => state.camera); - const domElement = useThree((state) => state.gl.domElement); - return ; +// Positive data spanning five orders of magnitude: three Gaussian peaks over a low background +function peak(x: number, y: number, params: [number, number, number, number]) { + const [cx, cy, amp, width] = params; + return amp * Math.exp(-((x - cx) ** 2 + (y - cy) ** 2) / (2 * width ** 2)); } + +const peaksArray = ndarray( + Float32Array.from({ length: 80 * 120 }, (_, i) => { + const x = (i % 120) / 120; + const y = Math.floor(i / 120) / 80; + return ( + 1e-4 + + peak(x, y, [0.3, 0.4, 1, 0.08]) + + peak(x, y, [0.7, 0.6, 0.05, 0.06]) + + peak(x, y, [0.55, 0.2, 0.003, 0.05]) + ); + }), + [80, 120], +); + +export const LogScale = meta.story({ + args: { + title: 'Peaks (log scale)', + dataArray: peaksArray, + domain: getDomain(peaksArray.data, { scaleType: ScaleType.Log }), + scaleType: ScaleType.Log, + }, +}); + +export const NaNHoles = meta.story({ + name: 'With NaN', + args: { + dataArray: withHoles, + }, +}); + +export const Downsampled = meta.story({ + args: { + maxVertices: 1000, + }, +}); diff --git a/apps/storybook/src/Utilities.mdx b/apps/storybook/src/Utilities.mdx index 9a9e3bdb5..f71a9278c 100644 --- a/apps/storybook/src/Utilities.mdx +++ b/apps/storybook/src/Utilities.mdx @@ -500,7 +500,7 @@ writing your own geometries: - `ErrorBarsGeometry`, used in [`ErrorBars`](https://h5web-docs.panosc.eu/?path=/docs/building-blocks-errorbars--docs) - `ErrorCapsGeometry`, used in [`ErrorBars`](https://h5web-docs.panosc.eu/?path=/docs/building-blocks-errorbars--docs) - `ScatterPointsGeometry`, used in [`ScatterPoints`](https://h5web-docs.panosc.eu/?path=/docs/visualizations-scattervis--docs) (undocumented) -- `SurfaceMeshGeometry`, used in [`SurfaceMesh`](https://h5web-docs.panosc.eu/?path=/docs/experimental-surfacevis--docs) (experimental) +- `SurfaceMeshGeometry`, used in [`SurfaceMesh`](https://h5web-docs.panosc.eu/?path=/docs/visualizations-surfacevis--docs) ### Constants diff --git a/cypress/snapshots/app.cy.ts/auximage.snap.png b/cypress/snapshots/app.cy.ts/auximage.snap.png index b68445d9d..770a89da2 100644 Binary files a/cypress/snapshots/app.cy.ts/auximage.snap.png and b/cypress/snapshots/app.cy.ts/auximage.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/default_slice.snap.png b/cypress/snapshots/app.cy.ts/default_slice.snap.png index 0e2b6cac6..3018e4a92 100644 Binary files a/cypress/snapshots/app.cy.ts/default_slice.snap.png and b/cypress/snapshots/app.cy.ts/default_slice.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 fca644b6c..8580f9cf8 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 fc340b83a..8fd06d8e0 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_inverted_cmap.snap.png b/cypress/snapshots/app.cy.ts/heatmap_2D_inverted_cmap.snap.png index 0f2561acc..ea6da3e31 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 ac95f6ad2..6bba025d8 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 dc600c562..6bf68b605 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 098b63993..74724be59 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 826e32a28..6334e88d3 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 d82690362..faad29bd7 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/heatmap_flip.snap.png b/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png index a3815e301..25648e97e 100644 Binary files a/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png and b/cypress/snapshots/app.cy.ts/heatmap_flip.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxheatmap.snap.png b/cypress/snapshots/app.cy.ts/nxheatmap.snap.png index 7860fd779..419966388 100644 Binary files a/cypress/snapshots/app.cy.ts/nxheatmap.snap.png and b/cypress/snapshots/app.cy.ts/nxheatmap.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png index ba1f3e255..8e9679b88 100644 Binary files a/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png and b/cypress/snapshots/app.cy.ts/nxheatmap_complex_2d_float_aux.snap.png differ diff --git a/cypress/snapshots/app.cy.ts/wide.snap.png b/cypress/snapshots/app.cy.ts/wide.snap.png index ae333174a..047db245c 100644 Binary files a/cypress/snapshots/app.cy.ts/wide.snap.png and b/cypress/snapshots/app.cy.ts/wide.snap.png differ diff --git a/packages/app/src/__tests__/HeatmapSurface.browser.test.ts b/packages/app/src/__tests__/HeatmapSurface.browser.test.ts new file mode 100644 index 000000000..e410319df --- /dev/null +++ b/packages/app/src/__tests__/HeatmapSurface.browser.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, expect, test } from 'vitest'; +import { page } from 'vitest/browser'; + +import { getSelectedVisTab, getVisTabs, renderApp } from '../test-utils'; +import { Vis } from '../vis-packs/core/visualizations'; +import { NxDataVis } from '../vis-packs/nexus/visualizations'; + +function surfaceBtn() { + return page.getByRole('button', { name: 'Show as 3D surface' }); +} + +function heightSlider() { + return page.getByRole('slider', { name: 'Height scale' }); +} + +function resetViewBtn() { + return page.getByRole('button', { name: 'Reset view' }); +} + +/* Each switch between heatmap and surface creates a new WebGL context, which takes ~0.7 s with + * SwiftShader on an idle machine (profiled: `getContext` dominates; geometry, labels and React work + * take a few ms) and more when many test files create contexts in parallel, as in CI. + * ("THREE.WebGLRenderer: Context Lost." is R3F disposing the previous canvas on unmount.) */ +const SURFACE_TEST_TIMEOUT = 20_000; + +/* In 3D, the toolbar gets wider (height slider), so at the default 1920px viewport + * it re-measures after the switch and moves its last controls (Flip Y, Keep ratio, Grid) into the + * "More controls" menu. Querying those controls during that re-layout made tests flaky (the button + * would detach and the locator would wait until timeout). Use a viewport wide enough for the whole + * 3D toolbar to fit, so no control moves. */ +beforeEach(async () => { + await page.viewport(2560, 1080); +}); + +afterEach(async () => { + await page.viewport(1920, 1080); +}); + +test( + 'extend heatmap into 3D surface and back', + async () => { + const { user } = await renderApp('/arrays/twoD'); + + expect(getVisTabs()).toEqual([Vis.Matrix, Vis.Line, Vis.Heatmap]); + expect(getSelectedVisTab()).toBe(Vis.Heatmap); + expect(surfaceBtn()).toHaveAttribute('aria-pressed', 'false'); + expect(heightSlider()).not.toBeInTheDocument(); + + await user.click(surfaceBtn()); + + // Same vis tab, same dataset, now rendered as a surface + expect(getSelectedVisTab()).toBe(Vis.Heatmap); + expect(surfaceBtn()).toHaveAttribute('aria-pressed', 'true'); + await expect + .element(page.getByRole('figure', { name: 'twoD' })) + .toBeVisible(); + await expect.element(resetViewBtn()).toBeVisible(); + expect(heightSlider()).toHaveValue('0.5'); + + await user.click(surfaceBtn()); + + expect(surfaceBtn()).toHaveAttribute('aria-pressed', 'false'); + expect(resetViewBtn()).not.toBeInTheDocument(); + expect(heightSlider()).not.toBeInTheDocument(); + }, + SURFACE_TEST_TIMEOUT, +); + +test( + 'carry heatmap settings over to 3D surface', + async () => { + const { user } = await renderApp('/arrays/twoD'); + + await user.click(page.getByRole('button', { name: 'Invert' })); + await user.click(page.getByRole('button', { name: 'Keep ratio' })); + + await user.click(surfaceBtn()); + await expect.element(resetViewBtn()).toBeVisible(); + + expect(page.getByRole('button', { name: 'Invert' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + expect(page.getByRole('button', { name: 'Keep ratio' })).toHaveAttribute( + 'aria-pressed', + 'false', + ); + }, + SURFACE_TEST_TIMEOUT, +); + +test( + 'keep settings changed in 3D when going back to heatmap', + async () => { + const { user } = await renderApp('/arrays/twoD'); + + await user.click(surfaceBtn()); + await user.click(page.getByRole('button', { name: 'Flip X' })); + await user.click(surfaceBtn()); + + expect(resetViewBtn()).not.toBeInTheDocument(); + expect(page.getByRole('button', { name: 'Flip X' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); + }, + SURFACE_TEST_TIMEOUT, +); + +test( + 'keep current slice and 3D mode when switching', + async () => { + const { user, selectExplorerNode } = await renderApp('/arrays/threeD'); + + const d0Slider = page.getByRole('slider', { name: 'D0' }); + await user.type(d0Slider, '{ArrowUp}'); + expect(d0Slider).toHaveValue(1); + + await user.click(surfaceBtn()); + await expect.element(resetViewBtn()).toBeVisible(); + expect(d0Slider).toHaveValue(1); + + // 3D mode is remembered when selecting another dataset + await selectExplorerNode('twoD'); + expect(surfaceBtn()).toHaveAttribute('aria-pressed', 'true'); + await expect + .element(page.getByRole('figure', { name: 'twoD' })) + .toBeVisible(); + await expect.element(resetViewBtn()).toBeVisible(); + }, + SURFACE_TEST_TIMEOUT, +); + +test( + 'show interactions help for 3D surface', + async () => { + const { user } = await renderApp('/arrays/twoD'); + await user.click(surfaceBtn()); + + await user.click(page.getByRole('button', { name: 'Show help' })); + expect(page.getByText('Rotate')).toBeVisible(); + expect(page.getByText('Select to zoom')).not.toBeInTheDocument(); + }, + SURFACE_TEST_TIMEOUT, +); + +test('offer 3D surface for NeXus images but not for complex heatmaps', async () => { + const { selectNexusExplorerNode } = await renderApp('/nexus/image'); + + expect(getSelectedVisTab()).toBe(NxDataVis.NxHeatmap); + expect(surfaceBtn()).toBeVisible(); + + await selectNexusExplorerNode('image_complex'); + expect(getSelectedVisTab()).toBe(NxDataVis.NxHeatmap); + expect(surfaceBtn()).not.toBeInTheDocument(); +}); + +test( + 'fall back to heatmap for slices with a single row or column', + async () => { + const { user, selectExplorerNode } = await renderApp('/arrays/twoD'); + await user.click(surfaceBtn()); + await expect.element(resetViewBtn()).toBeVisible(); + + // 1 x 10 dataset: no surface can be built + await selectExplorerNode('twoD_negative'); + expect(surfaceBtn()).not.toBeInTheDocument(); + expect(resetViewBtn()).not.toBeInTheDocument(); + await expect + .element(page.getByRole('figure', { name: 'twoD_negative' })) + .toBeVisible(); + }, + SURFACE_TEST_TIMEOUT, +); + +test( + 'show 3D surface of NeXus image with descending axes', + async () => { + const { user } = await renderApp('/nexus/descending-axes'); + await user.click(surfaceBtn()); + + await expect.element(resetViewBtn()).toBeVisible(); + expect(surfaceBtn()).toHaveAttribute('aria-pressed', 'true'); + }, + SURFACE_TEST_TIMEOUT, +); diff --git a/packages/app/src/vis-packs/core/heatmap/HeatmapToolbar.tsx b/packages/app/src/vis-packs/core/heatmap/HeatmapToolbar.tsx index 2af472a84..e4d3fd609 100644 --- a/packages/app/src/vis-packs/core/heatmap/HeatmapToolbar.tsx +++ b/packages/app/src/vis-packs/core/heatmap/HeatmapToolbar.tsx @@ -21,10 +21,12 @@ import { MdGridOn, MdSwapHoriz, MdSwapVert, + MdViewInAr, } from 'react-icons/md'; -import { getImageInteractions } from '../utils'; +import { getImageInteractions, SURFACE_INTERACTIONS } from '../utils'; import { type HeatmapConfig } from './config'; +import HeightScaleSlider from './HeightScaleSlider'; const COMPLEX_VIS_TYPES: ComplexHeatmapVisType[] = [ ComplexVisType.Amplitude, @@ -36,12 +38,14 @@ interface Props { dataDomain: Domain; isSlice?: boolean; isComplex?: boolean; + withSurface?: boolean; config: HeatmapConfig; exportEntries?: ExportEntry[]; } function HeatmapToolbar(props: Props) { - const { dataDomain, isSlice, isComplex, config, exportEntries } = props; + const { dataDomain, isSlice, isComplex, withSurface, config, exportEntries } = + props; const { customDomain, colorMap, @@ -52,6 +56,8 @@ function HeatmapToolbar(props: Props) { keepRatio, flipXAxis, flipYAxis, + surface, + heightScale, setCustomDomain, setColorMap, setInvertColorMap, @@ -61,10 +67,36 @@ function HeatmapToolbar(props: Props) { setKeepRatio, setFlipXAxis, setFlipYAxis, + setSurface, + setHeightScale, } = config; + const showSurface = withSurface && surface; + return ( - + + {withSurface && ( + <> + setSurface(!surface)} + /> + + {surface && ( + + )} + + + + )} + void; +} + +function HeightScaleSlider(props: Props) { + const { value, onChange } = props; + + return ( + + ); +} + +export default HeightScaleSlider; diff --git a/packages/app/src/vis-packs/core/heatmap/MappedHeatmapVis.tsx b/packages/app/src/vis-packs/core/heatmap/MappedHeatmapVis.tsx index cc48aa25c..8d3e98a8d 100644 --- a/packages/app/src/vis-packs/core/heatmap/MappedHeatmapVis.tsx +++ b/packages/app/src/vis-packs/core/heatmap/MappedHeatmapVis.tsx @@ -3,6 +3,7 @@ import { getSliceSelection, HeatmapVis, KeepZoom, + SurfaceVis, useDomain, useSafeDomain, useSlicedDimsAndMapping, @@ -62,6 +63,8 @@ function MappedHeatmapVis(props: Props) { invertColorMap, flipXAxis, flipYAxis, + surface, + heightScale, } = config; const ignoreValue = useIgnoreValue(dataset); @@ -82,6 +85,18 @@ function MappedHeatmapVis(props: Props) { const xDimIndex = dimMapping.indexOf('x'); const yDimIndex = dimMapping.indexOf('y'); + const abscissaParams = { + label: axisLabels[xDimIndex], + value: numAxisArrays[xDimIndex], + }; + const ordinateParams = { + label: axisLabels[yDimIndex], + value: numAxisArrays[yDimIndex], + }; + + // A surface needs at least two rows and two columns (e.g. not a 1 x N slice) + const canShowSurface = dataArray.shape.every((size) => size >= 2); + const exportEntries = useExportEntries(['tiff', 'npy'], dataset, selection); return ( @@ -91,37 +106,51 @@ function MappedHeatmapVis(props: Props) { , toolbarContainer, )} - - - + {surface && canShowSurface ? ( + + ) : ( + + + + )} ); } diff --git a/packages/app/src/vis-packs/core/heatmap/config.tsx b/packages/app/src/vis-packs/core/heatmap/config.tsx index 25c76dcc4..cca7cf671 100644 --- a/packages/app/src/vis-packs/core/heatmap/config.tsx +++ b/packages/app/src/vis-packs/core/heatmap/config.tsx @@ -44,6 +44,12 @@ export interface HeatmapConfig { flipYAxis: boolean; setFlipYAxis: (flipYAxis: boolean) => void; + + surface: boolean; + setSurface: (surface: boolean) => void; + + heightScale: number; + setHeightScale: (heightScale: number) => void; } function createHeatmapConfigStore() { @@ -76,10 +82,18 @@ function createHeatmapConfigStore() { flipXAxis: false, setFlipXAxis: (flipXAxis) => set({ flipXAxis }), + + surface: false, + setSurface: (surface) => set({ surface }), + + heightScale: 0.5, + setHeightScale: (heightScale) => set({ heightScale }), }), { name: 'h5web:heatmap', version: 12, + // 3D mode is opt-in for the session: don't open datasets in 3D after a reload + partialize: ({ surface, ...state }) => state, }, ), ); diff --git a/packages/app/src/vis-packs/core/surface/MappedSurfaceVis.tsx b/packages/app/src/vis-packs/core/surface/MappedSurfaceVis.tsx deleted file mode 100644 index 0a409b391..000000000 --- a/packages/app/src/vis-packs/core/surface/MappedSurfaceVis.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - type DimensionMapping, - SurfaceVis, - useDomain, - useSafeDomain, - useSlicedDimsAndMapping, - useVisDomain, -} from '@h5web/lib'; -import { - type ArrayShape, - type ArrayValue, - type Dataset, - type NumericType, -} from '@h5web/shared/hdf5-models'; -import { createPortal } from 'react-dom'; - -import visualizerStyles from '../../../visualizer/Visualizer.module.css'; -import { useMappedArray, useToNumArray } from '../hooks'; -import { DEFAULT_DOMAIN } from '../utils'; -import { type SurfaceConfig } from './config'; -import SurfaceToolbar from './SurfaceToolbar'; - -interface Props { - dataset: Dataset; - value: ArrayValue; - dimMapping: DimensionMapping; - toolbarContainer: HTMLDivElement | undefined; - config: SurfaceConfig; -} - -function MappedSurfaceVis(props: Props) { - const { dataset, value, dimMapping, toolbarContainer, config } = props; - const { customDomain, colorMap, scaleType, invertColorMap } = config; - - const numArray = useToNumArray(value); - - const { dims } = dataset.shape; - const mappingArgs = useSlicedDimsAndMapping(dims, dimMapping); - const dataArray = useMappedArray(numArray, ...mappingArgs); - - const dataDomain = useDomain(dataArray, { scaleType }) || DEFAULT_DOMAIN; - const visDomain = useVisDomain(customDomain, dataDomain); - const [safeDomain] = useSafeDomain(visDomain, dataDomain, scaleType); - - return ( - <> - {toolbarContainer && - createPortal( - , - toolbarContainer, - )} - - - - ); -} - -export default MappedSurfaceVis; diff --git a/packages/app/src/vis-packs/core/surface/SurfaceToolbar.tsx b/packages/app/src/vis-packs/core/surface/SurfaceToolbar.tsx deleted file mode 100644 index 014dea09d..000000000 --- a/packages/app/src/vis-packs/core/surface/SurfaceToolbar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { - ColorMapSelector, - type Domain, - DomainWidget, - ScaleSelector, - Separator, - SnapshotBtn, - Toolbar, -} from '@h5web/lib'; -import { COLOR_SCALE_TYPES } from '@h5web/shared/vis-models'; - -import { type SurfaceConfig } from './config'; - -interface Props { - dataDomain: Domain; - config: SurfaceConfig; -} - -function SurfaceToolbar(props: Props) { - const { dataDomain, config } = props; - const { - customDomain, - colorMap, - scaleType, - invertColorMap, - setCustomDomain, - setColorMap, - setScaleType, - setInvertColorMap, - } = config; - - return ( - - - - - setInvertColorMap(!invertColorMap)} - /> - - - - - - - - - - ); -} - -export default SurfaceToolbar; diff --git a/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx b/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx deleted file mode 100644 index 8071e8b56..000000000 --- a/packages/app/src/vis-packs/core/surface/SurfaceVisContainer.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { DimensionMapper, getSliceSelection } from '@h5web/lib'; -import { - assertArrayShape, - assertDataset, - assertMinDims, - assertNumericType, -} from '@h5web/shared/guards'; - -import { useDimMappingState } from '../../../dim-mapping-store'; -import { useValuesInCache } from '../../../hooks'; -import visualizerStyles from '../../../visualizer/Visualizer.module.css'; -import { type VisContainerProps } from '../../models'; -import VisBoundary from '../../VisBoundary'; -import ValueFetcher from '../ValueFetcher'; -import { useSurfaceConfig } from './config'; -import MappedSurfaceVis from './MappedSurfaceVis'; - -function SurfaceVisContainer(props: VisContainerProps) { - const { entity, toolbarContainer } = props; - assertDataset(entity); - assertArrayShape(entity); - assertMinDims(entity, 2); - assertNumericType(entity); - - const { dims } = entity.shape; - const [dimMapping, setDimMapping] = useDimMappingState({ - dims, - axesCount: 2, - }); - - const config = useSurfaceConfig(); - const selection = getSliceSelection(dimMapping); - - return ( - <> - - - ( - - )} - /> - - - ); -} - -export default SurfaceVisContainer; diff --git a/packages/app/src/vis-packs/core/surface/config.tsx b/packages/app/src/vis-packs/core/surface/config.tsx deleted file mode 100644 index 3017b67a5..000000000 --- a/packages/app/src/vis-packs/core/surface/config.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { type CustomDomain } from '@h5web/lib'; -import { type ColorScaleType, ScaleType } from '@h5web/shared/vis-models'; -import { - createContext, - type PropsWithChildren, - useContext, - useState, -} from 'react'; -import { createStore, type StoreApi, useStore } from 'zustand'; -import { persist } from 'zustand/middleware'; - -import { type ColorMap } from '../heatmap/models'; -import { useSuggestion } from '../hooks'; - -export interface SurfaceConfig { - customDomain: CustomDomain; - setCustomDomain: (customDomain: CustomDomain) => void; - - colorMap: ColorMap; - setColorMap: (colorMap: ColorMap) => void; - - invertColorMap: boolean; - setInvertColorMap: (invertColorMap: boolean) => void; - - scaleType: ColorScaleType; - setScaleType: (scaleType: ColorScaleType) => void; -} - -function createSurfaceConfigStore() { - return createStore()( - persist( - (set): SurfaceConfig => ({ - customDomain: [null, null], - setCustomDomain: (customDomain) => set({ customDomain }), - - colorMap: 'Viridis', - setColorMap: (colorMap) => set({ colorMap }), - - invertColorMap: false, - setInvertColorMap: (invertColorMap) => set({ invertColorMap }), - - scaleType: ScaleType.Linear, - setScaleType: (scaleType) => set({ scaleType }), - }), - { - name: 'h5web:surface', - version: 1, - }, - ), - ); -} - -const StoreContext = createContext({} as StoreApi); - -export function SurfaceConfigProvider(props: PropsWithChildren) { - const { children } = props; - - const [store] = useState(createSurfaceConfigStore); - - return {children}; -} - -export function useSurfaceConfig( - suggestions: Partial> = {}, -): SurfaceConfig { - const config = useStore(useContext(StoreContext)); - - const [scaleType, setScaleType] = useSuggestion( - suggestions.scaleType, - config.scaleType, - config.setScaleType, - ); - - return { ...config, scaleType, setScaleType }; -} diff --git a/packages/app/src/vis-packs/core/utils.ts b/packages/app/src/vis-packs/core/utils.ts index 340cb71ca..c8792d6c9 100644 --- a/packages/app/src/vis-packs/core/utils.ts +++ b/packages/app/src/vis-packs/core/utils.ts @@ -36,6 +36,12 @@ export const INTERACTIONS_WITH_AXIAL_ZOOM = [ { shortcut: 'Ctrl+Shift+Drag', description: 'Select to zoom in Y' }, ]; +export const SURFACE_INTERACTIONS = [ + { shortcut: 'Drag', description: 'Rotate' }, + { shortcut: 'Right-drag', description: 'Pan' }, + { shortcut: 'Wheel', description: 'Zoom' }, +]; + export const CURVE_TYPE_LABELS: Record = { [CurveType.LineOnly]: 'Line', [CurveType.GlyphsOnly]: 'Points', diff --git a/packages/app/src/vis-packs/core/visualizations.ts b/packages/app/src/vis-packs/core/visualizations.ts index f412cd659..82079be02 100644 --- a/packages/app/src/vis-packs/core/visualizations.ts +++ b/packages/app/src/vis-packs/core/visualizations.ts @@ -10,14 +10,7 @@ import { hasPrintableType, } from '@h5web/shared/guards'; import { type Dataset } from '@h5web/shared/hdf5-models'; -import { - FiActivity, - FiCode, - FiGrid, - FiImage, - FiMap, - FiPackage, -} from 'react-icons/fi'; +import { FiActivity, FiCode, FiGrid, FiImage, FiMap } from 'react-icons/fi'; import { type DataContextValue } from '../../providers/DataProvider'; import { findScalarStrAttr, getAttributeValue } from '../../utils'; @@ -39,8 +32,6 @@ import { RgbVisContainer, ScalarVisContainer, } from './containers'; -import { SurfaceConfigProvider } from './surface/config'; -import SurfaceVisContainer from './surface/SurfaceVisContainer'; export enum Vis { Scalar = 'Scalar', @@ -51,7 +42,6 @@ export enum Vis { ComplexLine = 'ComplexLine', RGB = 'RGB', Compound = 'Compound', - Surface = 'Surface', } export interface CoreVisDef extends VisDef { @@ -172,22 +162,4 @@ export const CORE_VIS = { ); }, }, - - [Vis.Surface]: { - name: Vis.Surface, - Icon: FiPackage, - Container: SurfaceVisContainer, - ConfigProvider: SurfaceConfigProvider, - supportsDataset: async (dataset) => { - // @ts-expect-error - Untyped global flag - const enableSurfaceVis = globalThis.H5WEB_EXPERIMENTAL as boolean; - - return ( - enableSurfaceVis && - hasArrayShape(dataset) && - hasMinDims(dataset, 2) && - hasNumericType(dataset) - ); - }, - }, } satisfies Record; diff --git a/packages/lib/src/vis/surface/SurfaceControls.tsx b/packages/lib/src/vis/surface/SurfaceControls.tsx new file mode 100644 index 000000000..11971e611 --- /dev/null +++ b/packages/lib/src/vis/surface/SurfaceControls.tsx @@ -0,0 +1,152 @@ +import { useThree } from '@react-three/fiber'; +import { type RefObject, useCallback, useEffect, useRef } from 'react'; +import { PerspectiveCamera, Vector3, type Vector3Tuple } from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +import { getFramingDistance } from './utils'; + +interface Props { + boxSize: Vector3Tuple; + resetRef: RefObject<(() => void) | undefined>; +} + +// Look at the surface from the front-left, slightly from above (z is up) +const VIEW_DIRECTION = new Vector3(-0.8, -1.4, 1).normalize(); +const FOV = 40; +const LABELS_MARGIN = 1.1; // leave some room around the box + +function isFiniteVector(vec: Vector3): boolean { + return ( + Number.isFinite(vec.x) && Number.isFinite(vec.y) && Number.isFinite(vec.z) + ); +} + +function getBoxRadius(w: number, h: number, zHeight: number): number { + return (LABELS_MARGIN * Math.hypot(w, h, zHeight)) / 2; +} + +function SurfaceControls(props: Props) { + const { boxSize, resetRef } = props; + const [w, h, zHeight] = boxSize; + + const camera = useThree((state) => state.camera); + const domElement = useThree((state) => state.gl.domElement); + const invalidate = useThree((state) => state.invalidate); + const aspect = useThree((state) => state.size.width / state.size.height); + + const controlsRef = useRef(undefined); + + useEffect(() => { + camera.up.set(0, 0, 1); + const controls = new OrbitControls(camera, domElement); + controls.zoomSpeed = 1.5; + controlsRef.current = controls; + + function handleChange() { + invalidate(); + } + controls.addEventListener('change', handleChange); + + return () => { + controls.removeEventListener('change', handleChange); + controls.dispose(); + controlsRef.current = undefined; + }; + }, [camera, domElement, invalidate]); + + const needsFramingRef = useRef(true); + const prevHeightRef = useRef(zHeight); + + // Look at whole surface from default angle + const frame = useCallback(() => { + const controls = controlsRef.current; + if (!controls || !(camera instanceof PerspectiveCamera)) { + return; + } + + const radius = getBoxRadius(w, h, zHeight); + const distance = getFramingDistance(radius, FOV, aspect); + if (distance === undefined || !Number.isFinite(distance)) { + needsFramingRef.current = true; // try again once canvas has a size + return; + } + + const target = new Vector3(0, 0, zHeight / 2); + + camera.fov = FOV; + camera.near = distance / 100; + camera.far = distance * 100; + camera.position.copy(target).addScaledVector(VIEW_DIRECTION, distance); + camera.updateProjectionMatrix(); + + controls.target.copy(target); + controls.minDistance = radius * 0.1; + controls.maxDistance = distance * 10; + controls.update(); + + needsFramingRef.current = false; + prevHeightRef.current = zHeight; + invalidate(); + }, [camera, w, h, zHeight, aspect, invalidate]); + + const frameRef = useRef(frame); + frameRef.current = frame; + + // Frame on load and when footprint changes (i.e. new data or axes) + useEffect(() => { + needsFramingRef.current = true; + frameRef.current(); + }, [w, h]); + + // Frame once canvas gets a valid size, if framing was skipped; otherwise, keep user's view on resize + useEffect(() => { + if (needsFramingRef.current) { + frameRef.current(); + } + }, [aspect]); + + // When height scale changes, keep user's view angle, zoom and pan, relative to the box + useEffect(() => { + const controls = controlsRef.current; + const prevHeight = prevHeightRef.current; + if (!controls || needsFramingRef.current || prevHeight === zHeight) { + return; + } + + const zRatio = zHeight / prevHeight; + const sizeRatio = + getBoxRadius(w, h, zHeight) / getBoxRadius(w, h, prevHeight); + + const offset = camera.position.clone().sub(controls.target); + const target = controls.target.clone(); + target.z *= zRatio; + offset.multiplyScalar(sizeRatio); + const position = target.clone().add(offset); + + if (!isFiniteVector(target) || !isFiniteVector(position)) { + return; + } + + controls.target.copy(target); + camera.position.copy(position); + if (camera instanceof PerspectiveCamera) { + camera.near *= sizeRatio; + camera.far *= sizeRatio; + camera.updateProjectionMatrix(); + } + controls.minDistance *= sizeRatio; + controls.maxDistance *= sizeRatio; + controls.update(); + + prevHeightRef.current = zHeight; + invalidate(); + }, [camera, w, h, zHeight, invalidate]); + + useEffect(() => { + resetRef.current = frame; + }, [resetRef, frame]); + + return null; +} + +export default SurfaceControls; diff --git a/packages/lib/src/vis/surface/SurfaceMesh.tsx b/packages/lib/src/vis/surface/SurfaceMesh.tsx index a105cb43d..444374fde 100644 --- a/packages/lib/src/vis/surface/SurfaceMesh.tsx +++ b/packages/lib/src/vis/surface/SurfaceMesh.tsx @@ -1,59 +1,79 @@ import { - type ColorScaleType, type Domain, + type IgnoreValue, type NumArray, } from '@h5web/shared/vis-models'; +import { useThree } from '@react-three/fiber'; import { type NdArray } from 'ndarray'; -import { useMemo } from 'react'; -import { LinearFilter } from 'three'; +import { useEffect, useLayoutEffect, useMemo } from 'react'; +import { DoubleSide } from 'three'; -import HeatmapMaterial from '../heatmap/HeatmapMaterial'; -import { useTextureSafeNdArray } from '../heatmap/hooks'; import { type ColorMap } from '../heatmap/models'; import { useUpdateGeometry } from '../hooks'; -import GlyphMaterial from '../line/GlyphMaterial'; -import { GlyphType } from '../line/models'; +import { type VisScaleType } from '../models'; import SurfaceMeshGeometry from './surfaceMeshGeometry'; +import { getLinearColorLut } from './utils'; interface Props { - dataArray: NdArray; + values: NdArray; + xCoords: number[]; + yCoords: number[]; domain: Domain; - scaleType: ColorScaleType; + scaleType: VisScaleType; colorMap: ColorMap; invertColorMap: boolean; - showPoints: boolean; + ignoreValue?: IgnoreValue; + zHeight: number; } function SurfaceMesh(props: Props) { - const { dataArray, domain, colorMap, invertColorMap, scaleType, showPoints } = - props; + const { + values, + xCoords, + yCoords, + domain, + scaleType, + colorMap, + invertColorMap, + ignoreValue, + zHeight, + } = props; - const [rows, cols] = dataArray.shape; - const safeDataArray = useTextureSafeNdArray(dataArray); + const invalidate = useThree((state) => state.invalidate); + const colorLut = useMemo( + () => getLinearColorLut(colorMap, invertColorMap), + [colorMap, invertColorMap], + ); + + // Update positions only when data, scale or domain change const geometry = useMemo( - () => new SurfaceMeshGeometry({ dataArray, rows, cols }), - [dataArray, rows, cols], + () => + new SurfaceMeshGeometry({ + values, + xCoords, + yCoords, + domain, + scaleType, + ignoreValue, + }), + [values, xCoords, yCoords, domain, scaleType, ignoreValue], ); useUpdateGeometry(geometry); + useEffect(() => () => geometry.dispose(), [geometry]); + + // Update colours alone when colour map changes (after positions, before next frame) + useLayoutEffect(() => { + geometry.updateColors(colorLut); + invalidate(); + }, [geometry, colorLut, invalidate]); + // Geometry height is normalized and stretched by the mesh scale return ( - - - - - - - - + + + ); } diff --git a/packages/lib/src/vis/surface/SurfaceVis.module.css b/packages/lib/src/vis/surface/SurfaceVis.module.css index 09f7e0e40..b13ee8a99 100644 --- a/packages/lib/src/vis/surface/SurfaceVis.module.css +++ b/packages/lib/src/vis/surface/SurfaceVis.module.css @@ -6,6 +6,57 @@ margin: 0; } -.canvas { +.main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; margin-right: 1rem; } + +.title { + display: flex; + justify-content: center; + margin: 0; + padding: 0.25rem 0; + color: var(--h5w-plotTitle--color, inherit); + font-family: var(--h5w-plotTitle--fontFamily, inherit); + font-size: var(--h5w-plotTitle--fontSize, 1.125em); + font-weight: var(--h5w-plotTitle--fontWeight, inherit); +} + +.canvasArea { + position: relative; /* for reset button */ + flex: 1; + display: flex; + min-height: 0; + background-color: var(--h5w-canvas--bgColor, transparent); +} + +.canvas { + cursor: grab; + touch-action: none; +} + +.canvas:active { + cursor: grabbing; +} + +.resetBtn { + composes: btn from '../../toolbar/utils.module.css'; + position: absolute; + top: 0; + right: 0; + padding: 0.5rem; + font-size: 0.75em; +} + +.btnLike { + composes: btnLike from '../../toolbar/utils.module.css'; + height: 1.625rem; + background-color: rgba(255, 255, 255, 0.7); + box-shadow: + rgba(0, 0, 0, 0.1) 0px 0px 0px 1px, + rgba(0, 0, 0, 0.1) 0px 4px 11px; +} diff --git a/packages/lib/src/vis/surface/SurfaceVis.tsx b/packages/lib/src/vis/surface/SurfaceVis.tsx index 6d148fd04..7b46e6630 100644 --- a/packages/lib/src/vis/surface/SurfaceVis.tsx +++ b/packages/lib/src/vis/surface/SurfaceVis.tsx @@ -1,58 +1,172 @@ import { - type ColorScaleType, type Domain, + type IgnoreValue, type NumArray, ScaleType, } from '@h5web/shared/vis-models'; import { type NdArray } from 'ndarray'; -import { type PropsWithChildren } from 'react'; +import { type PropsWithChildren, useMemo, useRef } from 'react'; import ColorBar from '../heatmap/ColorBar'; import { type ColorMap } from '../heatmap/models'; -import { type ClassStyleAttrs } from '../models'; +import { + type AxisParams, + type ClassStyleAttrs, + type VisScaleType, +} from '../models'; import R3FCanvas from '../shared/R3FCanvas'; +import { createScale, DEFAULT_DOMAIN } from '../utils'; +import SurfaceControls from './SurfaceControls'; import SurfaceMesh from './SurfaceMesh'; import styles from './SurfaceVis.module.css'; +import { + DEFAULT_MAX_VERTICES, + getCellCenters, + getCoordsDomain, + getDownsamplingStride, + getFootprint, + getSampledIndices, + isSurfaceSupported, + sampleArray, +} from './utils'; interface Props extends ClassStyleAttrs { dataArray: NdArray; - domain: Domain; - scaleType?: ColorScaleType; + domain: Domain | undefined; colorMap?: ColorMap; + scaleType?: VisScaleType; invertColorMap?: boolean; - showPoints?: boolean; + title?: string; + abscissaParams?: AxisParams; + ordinateParams?: AxisParams; + flipXAxis?: boolean; + flipYAxis?: boolean; + keepRatio?: boolean; + heightScale?: number; + ignoreValue?: IgnoreValue; + maxVertices?: number; } function SurfaceVis(props: PropsWithChildren) { const { dataArray, - domain, + domain = DEFAULT_DOMAIN, colorMap = 'Viridis', scaleType = ScaleType.Linear, invertColorMap = false, - showPoints = false, + title, + abscissaParams = {}, + ordinateParams = {}, + flipXAxis = false, + flipYAxis = false, + keepRatio = true, + heightScale = 0.5, + ignoreValue, + maxVertices = DEFAULT_MAX_VERTICES, children, className = '', style, } = props; + if (dataArray.shape.length !== 2) { + throw new Error('Expected two-dimensional array'); + } + + if (!isSurfaceSupported(dataArray.shape)) { + throw new Error('Expected at least two rows and two columns'); + } + + const { value: abscissaValue } = abscissaParams; + const { value: ordinateValue } = ordinateParams; + const [rows, cols] = dataArray.shape; + + const xCenters = useMemo( + () => getCellCenters(abscissaValue, cols), + [abscissaValue, cols], + ); + const yCenters = useMemo( + () => getCellCenters(ordinateValue, rows), + [ordinateValue, rows], + ); + + const xDomain = useMemo(() => getCoordsDomain(xCenters), [xCenters]); + const yDomain = useMemo(() => getCoordsDomain(yCenters), [yCenters]); + const [w, h] = getFootprint(xDomain, yDomain, keepRatio); + + // Downsample large datasets + const { values, xCoords, yCoords } = useMemo(() => { + const stride = getDownsamplingStride(rows, cols, maxVertices); + const rowIndices = getSampledIndices(rows, stride); + const colIndices = getSampledIndices(cols, stride); + + const xScale = createScale(ScaleType.Linear, { + domain: xDomain, + range: flipXAxis ? [w / 2, -w / 2] : [-w / 2, w / 2], + }); + const yScale = createScale(ScaleType.Linear, { + domain: yDomain, + range: flipYAxis ? [h / 2, -h / 2] : [-h / 2, h / 2], + }); + + return { + values: sampleArray(dataArray, rowIndices, colIndices), + xCoords: colIndices.map((i) => xScale(xCenters[i])), + yCoords: rowIndices.map((i) => yScale(yCenters[i])), + }; + }, [ + dataArray, + rows, + cols, + maxVertices, + xCenters, + yCenters, + xDomain, + yDomain, + w, + h, + flipXAxis, + flipYAxis, + ]); + + const resetRef = useRef<() => void>(undefined); + return (
- - - {children} - +
+ {title &&

{title}

} +
+ + + + {children} + + +
+
[0], +) { + const geometry = new SurfaceMeshGeometry(params); + geometry.update(); + assertNonNull(geometry.index); + + const { position, color } = geometry.attributes; + const index = geometry.index.array.slice(0, geometry.drawRange.count); + return { geometry, positions: position.array, index, colors: color.array }; +} + +describe('SurfaceMeshGeometry', () => { + it('should normalize heights with scale and create two triangles per cell', () => { + const values = ndarray([0, 5, 10, 10], [2, 2]); + const { positions, index } = buildGeometry({ + values, + xCoords: [-0.5, 0.5], + yCoords: [-0.25, 0.25], + domain: [0, 10], + scaleType: ScaleType.Linear, + }); + + expect([...positions]).toEqual([ + -0.5, -0.25, 0, 0.5, -0.25, 0.5, -0.5, 0.25, 1, 0.5, 0.25, 1, + ]); + expect([...index]).toEqual([0, 1, 2, 1, 3, 2]); + }); + + it('should clamp heights to domain', () => { + const values = ndarray([-5, 20, 0, 0], [2, 2]); + const { positions } = buildGeometry({ + values, + xCoords: [0, 1], + yCoords: [0, 1], + domain: [0, 10], + scaleType: ScaleType.Linear, + }); + + expect(positions[2]).toBe(0); + expect(positions[5]).toBe(1); + }); + + it('should use same scale for height as for colours (log)', () => { + const values = ndarray([1, 10, 100, 1000], [2, 2]); + const { positions } = buildGeometry({ + values, + xCoords: [0, 1], + yCoords: [0, 1], + domain: [1, 1000], + scaleType: ScaleType.Log, + }); + + const heights = [positions[2], positions[5], positions[8], positions[11]]; + expect(heights[0]).toBe(0); + expect(heights[1]).toBeCloseTo(1 / 3); + expect(heights[2]).toBeCloseTo(2 / 3); + expect(heights[3]).toBeCloseTo(1); + }); + + it('should leave holes for NaN, ignored and unsupported values', () => { + // 3x3 grid -> 4 cells; each corner cell has a bad value + const values = ndarray([1, 2, Number.NaN, 4, 5, 6, -7, 8, 99], [3, 3]); + const { index } = buildGeometry({ + values, + xCoords: [0, 1, 2], + yCoords: [0, 1, 2], + domain: [1, 10], + scaleType: ScaleType.Log, // -7 not supported + ignoreValue: (val) => val === 99, + }); + + // Only bottom-left cell (values 1, 2, 4, 5) remains + expect([...index]).toEqual([0, 1, 3, 1, 4, 3]); + }); + + it('should leave holes for non-finite axis coordinates', () => { + const { positions, index } = buildGeometry({ + values: ndarray([1, 2, 3, 4, 5, 6], [2, 3]), + xCoords: [0, Number.NaN, 2], + yCoords: [0, 1], + domain: [1, 6], + scaleType: ScaleType.Linear, + }); + + expect(index).toHaveLength(0); // both cells touch column with NaN coordinate + expect(positions.every(Number.isFinite)).toBe(true); + }); + + it('should produce no triangle with a single row or column', () => { + const { index } = buildGeometry({ + values: ndarray([1, 2, 3], [1, 3]), + xCoords: [0, 1, 2], + yCoords: [0], + domain: [1, 3], + scaleType: ScaleType.Linear, + }); + + expect(index).toHaveLength(0); + }); + + it('should map normalized heights through colour map', () => { + const colorLut = getLinearColorLut('Greys', false); + const { geometry, colors } = buildGeometry({ + values: ndarray([0, 10, 0, 10], [2, 2]), + xCoords: [0, 1], + yCoords: [0, 1], + domain: [0, 10], + scaleType: ScaleType.Linear, + }); + + geometry.updateColors(colorLut); + + // Min value gets first colour of LUT, max value gets last colour + expect(colors.slice(0, 3)).toEqual(colorLut.slice(0, 3)); + expect(colors.slice(3, 6)).toEqual(colorLut.slice(-3)); + expect(colorLut[0]).not.toBe(colorLut.at(-1)); + }); +}); diff --git a/packages/lib/src/vis/surface/surfaceMeshGeometry.ts b/packages/lib/src/vis/surface/surfaceMeshGeometry.ts index 465102414..a3be3b2fc 100644 --- a/packages/lib/src/vis/surface/surfaceMeshGeometry.ts +++ b/packages/lib/src/vis/surface/surfaceMeshGeometry.ts @@ -1,67 +1,146 @@ import { assertNonNull } from '@h5web/shared/guards'; -import { type NumArray } from '@h5web/shared/vis-models'; +import { + type Domain, + type IgnoreValue, + type NumArray, +} from '@h5web/shared/vis-models'; import { type NdArray } from 'ndarray'; import { type BufferAttribute, BufferGeometry } from 'three'; -import { type H5WebGeometry } from '../models'; -import { createBufferAttr, createIndex } from '../utils'; +import { type H5WebGeometry, type VisScaleType } from '../models'; +import { + createBufferAttr, + createIndex, + createScale, + SCALES_VALID_MINS, +} from '../utils'; interface Params { - dataArray: NdArray; - rows: number; - cols: number; + values: NdArray; + xCoords: number[]; // world x of each column + yCoords: number[]; // world y of each row + domain: Domain; + scaleType: VisScaleType; + ignoreValue?: IgnoreValue; } class SurfaceMeshGeometry - extends BufferGeometry> + extends BufferGeometry> implements H5WebGeometry { - private skipCount = 0; + private readonly valid: Uint8Array; public constructor(private readonly params: Params) { super(); - const { dataArray, rows, cols } = params; - const { size } = dataArray; + const { values } = params; + const { size } = values; + const [rows, cols] = values.shape; this.setAttribute('position', createBufferAttr(size)); - this.setAttribute('uv', createBufferAttr(size, 2)); - - const positionsToSkip = rows + cols - 1; // skip last col and last row - const indicesCount = (size - positionsToSkip) * 2 * 3; // 2 triangles per position, 3 indices per triangle + this.setAttribute('color', createBufferAttr(size)); + const indicesCount = (rows - 1) * (cols - 1) * 2 * 3; // 2 triangles per cell, 3 indices per triangle this.index = createIndex(indicesCount, size); - this.skipCount = 0; // to keep track of skipped positions during update + this.valid = new Uint8Array(size); } + /** + * Height is normalized to [0, 1] with the same scale and domain as the colour, + * so height and colour always tell the same story. + * Cells touching a NaN, an ignored value or a value that is not supported + * by the scale (e.g. negative values in log) are left out: they appear as holes. + */ public update(): void { - const { dataArray, rows, cols } = this.params; + const { values, xCoords, yCoords, domain, scaleType, ignoreValue } = + this.params; + const [rows, cols] = values.shape; assertNonNull(this.index); - for (const [index, value] of dataArray.data.entries()) { - const row = Math.floor(index / cols); - const col = index % cols; - - this.attributes.position.setXYZ(index, col, row, value); - this.attributes.uv.setXY(index, (col + 0.5) / cols, (row + 0.5) / rows); + const scale = createScale(scaleType, { + domain, + range: [0, 1], + clamp: true, + }); + const validMin = + SCALES_VALID_MINS[Array.isArray(scaleType) ? scaleType[0] : scaleType]; + + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const index = row * cols + col; + const value = values.get(row, col); // `values` may be a view + const x = xCoords[col]; + const y = yCoords[row]; + const z = scale(value); + + // Keep positions of unused vertices finite, so bounds stay valid + const isValidXY = Number.isFinite(x) && Number.isFinite(y); + const isValid = + isValidXY && + Number.isFinite(value) && + value >= validMin && + !ignoreValue?.(value) && + Number.isFinite(z); + + this.attributes.position.setXYZ( + index, + isValidXY ? x : 0, + isValidXY ? y : 0, + isValid ? z : 0, + ); + this.valid[index] = isValid ? 1 : 0; + } + } - if (row === rows - 1 || col === cols - 1) { - this.skipCount++; - continue; + let posIndex = 0; + for (let row = 0; row < rows - 1; row += 1) { + for (let col = 0; col < cols - 1; col += 1) { + const index = row * cols + col; + const corners = [index, index + 1, index + cols, index + cols + 1]; + if (!corners.every((i) => this.valid[i])) { + continue; + } + + // First triangle: (x,y) -> (x + 1, y) -> (x, y + 1) + this.index.setX(posIndex, index); + this.index.setX(posIndex + 1, index + 1); + this.index.setX(posIndex + 2, index + cols); + + // Second triangle: (x + 1, y) -> (x + 1, y + 1) -> (x, y + 1) + this.index.setX(posIndex + 3, index + 1); + this.index.setX(posIndex + 4, index + cols + 1); + this.index.setX(posIndex + 5, index + cols); + + posIndex += 6; } + } - const posIndex = (index - this.skipCount) * 6; + // Unused indices are not drawn + this.index.array.fill(0, posIndex); + this.setDrawRange(0, posIndex); - // First triangle: (x,y) -> (x, y + 1) -> (x + 1, y) - this.index.setX(posIndex, index); - this.index.setX(posIndex + 1, index + 1); - this.index.setX(posIndex + 2, index + cols); + this.computeBoundingSphere(); + } - // Second triangle: (x, y + 1) -> (x + 1 ,y) -> (x + 1, y + 1) - this.index.setX(posIndex + 3, index + 1); - this.index.setX(posIndex + 4, index + cols); - this.index.setX(posIndex + 5, index + cols + 1); + /** + * Vertex colours (linear RGB) from normalized heights: since height and colour share + * the same scale and domain, colours can be updated without updating positions. + */ + public updateColors(colorLut: Float32Array): void { + const { position, color } = this.attributes; + const lutMax = colorLut.length / 3 - 1; + + for (let i = 0; i < position.count; i += 1) { + const lutIndex = Math.round(position.getZ(i) * lutMax) * 3; + color.setXYZ( + i, + colorLut[lutIndex], + colorLut[lutIndex + 1], + colorLut[lutIndex + 2], + ); } + + color.needsUpdate = true; } } diff --git a/packages/lib/src/vis/surface/utils.test.ts b/packages/lib/src/vis/surface/utils.test.ts new file mode 100644 index 000000000..19a04d64f --- /dev/null +++ b/packages/lib/src/vis/surface/utils.test.ts @@ -0,0 +1,142 @@ +import ndarray from 'ndarray'; +import { describe, expect, it } from 'vitest'; + +import { + getCellCenters, + getCoordsDomain, + getDownsamplingStride, + getFootprint, + getFramingDistance, + getSampledIndices, + isSurfaceSupported, + sampleArray, +} from './utils'; + +function getVertexCount(rows: number, cols: number, stride: number): number { + return ( + getSampledIndices(rows, stride).length * + getSampledIndices(cols, stride).length + ); +} + +describe('getDownsamplingStride', () => { + it('should not downsample below vertex limit', () => { + expect(getDownsamplingStride(100, 100, 10_000)).toBe(1); + }); + + it('should return smallest stride that keeps vertex count under limit', () => { + for (const [rows, cols] of [ + [4000, 4000], + [2000, 3000], + [1500, 1100], + ]) { + const stride = getDownsamplingStride(rows, cols, 1_000_000); + expect(getVertexCount(rows, cols, stride)).toBeLessThanOrEqual(1_000_000); + expect(getVertexCount(rows, cols, stride - 1)).toBeGreaterThan(1_000_000); + } + }); + + it('should enforce vertex limit for elongated arrays', () => { + const stride = getDownsamplingStride(2, 8_388_608, 1_048_576); + expect(getVertexCount(2, 8_388_608, stride)).toBeLessThanOrEqual(1_048_576); + }); + + it('should not loop forever when limit cannot be met', () => { + expect(getDownsamplingStride(3, 3, 1)).toBe(3); + }); +}); + +describe('getSampledIndices', () => { + it('should return every index with stride 1', () => { + expect(getSampledIndices(4, 1)).toEqual([0, 1, 2, 3]); + }); + + it('should always include last index', () => { + expect(getSampledIndices(10, 3)).toEqual([0, 3, 6, 9]); + expect(getSampledIndices(11, 3)).toEqual([0, 3, 6, 9, 10]); + }); +}); + +describe('sampleArray', () => { + it('should pick given rows and columns', () => { + const arr = ndarray( + Float32Array.from({ length: 12 }, (_, i) => i), + [3, 4], + ); + const sampled = sampleArray(arr, [0, 2], [1, 3]); + expect(sampled.shape).toEqual([2, 2]); + expect([...sampled.data]).toEqual([1, 3, 9, 11]); + }); + + it('should return same array when picking everything', () => { + const arr = ndarray([1, 2, 3, 4], [2, 2]); + expect(sampleArray(arr, [0, 1], [0, 1])).toBe(arr); + }); +}); + +describe('getCellCenters', () => { + it('should return indices when no axis values', () => { + expect(getCellCenters(undefined, 3)).toEqual([0, 1, 2]); + }); + + it('should return axis values as is when one per cell', () => { + expect(getCellCenters([10, 20, 40], 3)).toEqual([10, 20, 40]); + }); + + it('should return mid-points when axis values are pixel edges', () => { + expect(getCellCenters([0, 2, 6, 8], 3)).toEqual([1, 4, 7]); + }); +}); + +describe('getCoordsDomain', () => { + it('should return extent of finite coordinates', () => { + expect(getCoordsDomain([-1, Number.NaN, 3, 2])).toEqual([-1, 3]); + }); + + it('should keep descending axes descending, like HeatmapVis', () => { + expect(getCoordsDomain([3, 2, 1, 0])).toEqual([3, 0]); + }); + + it('should extend degenerate domains', () => { + expect(getCoordsDomain([5])).toEqual([4.5, 5.5]); + expect(getCoordsDomain([])).toEqual([-0.5, 0.5]); + }); +}); + +describe('getFootprint', () => { + it('should return square footprint when not keeping ratio', () => { + expect(getFootprint([0, 100], [0, 10], false)).toEqual([1, 1]); + }); + + it('should respect ratio between axis extents with longest side of 1', () => { + expect(getFootprint([0, 40], [0, 20], true)).toEqual([1, 0.5]); + expect(getFootprint([0, 10], [-20, 20], true)).toEqual([0.25, 1]); + }); +}); + +describe('isSurfaceSupported', () => { + it('should require at least two rows and two columns', () => { + expect(isSurfaceSupported([1, 3])).toBe(false); + expect(isSurfaceSupported([3, 1])).toBe(false); + expect(isSurfaceSupported([2, 2])).toBe(true); + }); +}); + +describe('getFramingDistance', () => { + it('should fit sphere vertically in landscape viewport', () => { + // sin(45 / 2) ~ 0.383 + expect(getFramingDistance(1, 45, 2)).toBeCloseTo(2.613, 3); + }); + + it('should fit sphere horizontally in portrait viewport', () => { + expect(getFramingDistance(1, 45, 0.5)).toBeGreaterThan( + getFramingDistance(1, 45, 1) ?? Infinity, + ); + }); + + it('should return undefined for zero-sized viewport', () => { + expect(getFramingDistance(1, 45, Number.NaN)).toBeUndefined(); + expect(getFramingDistance(1, 45, Infinity)).toBeUndefined(); + expect(getFramingDistance(1, 45, 0)).toBeUndefined(); + }); +}); diff --git a/packages/lib/src/vis/surface/utils.ts b/packages/lib/src/vis/surface/utils.ts new file mode 100644 index 000000000..7e4bd5b4a --- /dev/null +++ b/packages/lib/src/vis/surface/utils.ts @@ -0,0 +1,180 @@ +import { type Domain, type NumArray } from '@h5web/shared/vis-models'; +import { rgb } from 'd3-color'; +import ndarray, { type NdArray } from 'ndarray'; +import { Color, SRGBColorSpace } from 'three'; + +import { type ColorMap } from '../heatmap/models'; +import { getInterpolator } from '../heatmap/utils'; +import { getAxisDomain } from '../utils'; + +/* Above this number of vertices, the surface is downsampled (by decimation) + * to keep geometry generation and rendering responsive (~1024 x 1024). */ +export const DEFAULT_MAX_VERTICES = 1024 * 1024; + +const LUT_SIZE = 256; + +/** Surfaces need at least two rows and two columns to have any triangle. */ +export function isSurfaceSupported(shape: number[]): boolean { + return shape.length === 2 && shape[0] >= 2 && shape[1] >= 2; +} + +/** + * Stride to apply along both dimensions so that the + * number of vertices does not exceed `maxVertices`. + */ +export function getDownsamplingStride( + rows: number, + cols: number, + maxVertices = DEFAULT_MAX_VERTICES, +): number { + if (rows * cols <= maxVertices) { + return 1; + } + + // Initial estimate, then account for dimensions that cannot shrink proportionally + // (e.g. very elongated arrays) and for last index always being sampled + let stride = Math.ceil(Math.sqrt((rows * cols) / maxVertices)); + const maxStride = Math.max(rows, cols); + + while ( + stride < maxStride && + getSampledCount(rows, stride) * getSampledCount(cols, stride) > maxVertices + ) { + stride += 1; + } + + return stride; +} + +function getSampledCount(length: number, stride: number): number { + return Math.ceil((length - 1) / stride) + 1; +} + +/** + * Indices `0, stride, 2 * stride, ...` along a dimension of given length, + * always including the last index so the full extent of the data is preserved. + */ +export function getSampledIndices(length: number, stride: number): number[] { + const indices: number[] = []; + for (let i = 0; i < length; i += stride) { + indices.push(i); + } + + if (length > 0 && indices[indices.length - 1] !== length - 1) { + indices.push(length - 1); + } + + return indices; +} + +/** Pick the given rows and columns of a 2D array (nearest-neighbour decimation). */ +export function sampleArray( + values: NdArray, + rowIndices: number[], + colIndices: number[], +): NdArray { + const [rows, cols] = values.shape; + if (rowIndices.length === rows && colIndices.length === cols) { + return values; + } + + const data = new Float64Array(rowIndices.length * colIndices.length); + rowIndices.forEach((row, i) => { + colIndices.forEach((col, j) => { + data[i * colIndices.length + j] = values.get(row, col); + }); + }); + + return ndarray(data, [rowIndices.length, colIndices.length]); +} + +/** + * Coordinates of the centres of the data cells along a dimension of size `count`: + * - indices if no axis values are provided; + * - mid-points if axis values are pixel edges (i.e. `count + 1` values); + * - axis values as is otherwise. + */ +export function getCellCenters( + axisValues: NumArray | undefined, + count: number, +): number[] { + if (!axisValues || axisValues.length < count) { + return Array.from({ length: count }, (_, i) => i); + } + + if (axisValues.length > count) { + return Array.from( + { length: count }, + (_, i) => (axisValues[i] + axisValues[i + 1]) / 2, + ); + } + + return [...axisValues]; +} + +/** + * Domain of an axis, kept descending if the axis values are descending (like in `HeatmapVis`), + * so that the surface is not mirrored compared to the heatmap. + */ +export function getCoordsDomain(coords: number[]): Domain { + const domain = getAxisDomain(coords) ?? [-0.5, 0.5]; + const [first, last] = domain; + if (first !== last) { + return domain; + } + + return [first - 0.5, last + 0.5]; +} + +/** + * Size of the surface's footprint in world units - the longest side always measures 1. + * When `keepRatio` is true, the ratio between the axis extents is respected. + */ +export function getFootprint( + xDomain: Domain, + yDomain: Domain, + keepRatio: boolean, +): [number, number] { + if (!keepRatio) { + return [1, 1]; + } + + const ratio = Math.abs((xDomain[1] - xDomain[0]) / (yDomain[1] - yDomain[0])); + return ratio >= 1 ? [1, 1 / ratio] : [ratio, 1]; +} + +/** Colour map lookup table in linear RGB, as expected by three.js vertex colours. */ +export function getLinearColorLut( + colorMap: ColorMap, + invertColorMap: boolean, +): Float32Array { + const interpolator = getInterpolator(colorMap, invertColorMap); + const lut = new Float32Array(LUT_SIZE * 3); + const color = new Color(); + + for (let i = 0; i < LUT_SIZE; i += 1) { + const { r, g, b } = rgb(interpolator(i / (LUT_SIZE - 1))); + color.setRGB(r / 255, g / 255, b / 255, SRGBColorSpace); // converts to linear + lut.set([color.r, color.g, color.b], i * 3); + } + + return lut; +} + +/** + * Distance from which a perspective camera sees a whole sphere of given radius, + * whatever the aspect ratio of the viewport. + */ +export function getFramingDistance( + radius: number, + fov: number, // vertical, in degrees + aspect: number, +): number | undefined { + if (!Number.isFinite(aspect) || aspect <= 0 || radius <= 0) { + return undefined; // e.g. canvas not laid out yet (zero width or height) + } + + const vFov = (fov * Math.PI) / 180; + const hFov = 2 * Math.atan(Math.tan(vFov / 2) * aspect); + return radius / Math.sin(Math.min(vFov, hFov) / 2); +}