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..f56f65d71 100644 --- a/apps/storybook/src/SurfaceVis.stories.tsx +++ b/apps/storybook/src/SurfaceVis.stories.tsx @@ -1,41 +1,97 @@ 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: { label: 'Position (mm)', value: abscissas }, + ordinateParams: { label: 'Time (s)', value: ordinates }, + valueLabel: 'Intensity', }, }); -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, + valueLabel: 'Counts', + }, +}); + +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..3fed9531a 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,53 @@ 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/SurfaceAxes.tsx b/packages/lib/src/vis/surface/SurfaceAxes.tsx new file mode 100644 index 000000000..70721f416 --- /dev/null +++ b/packages/lib/src/vis/surface/SurfaceAxes.tsx @@ -0,0 +1,413 @@ +import { type Domain, ScaleType } from '@h5web/shared/vis-models'; +import { formatTick } from '@h5web/shared/vis-utils'; +import { type Size, useFrame, useThree } from '@react-three/fiber'; +import { type RefObject, useEffect, useMemo, useRef, useState } from 'react'; +import { + BufferAttribute, + BufferGeometry, + type Camera, + type Group, + type Sprite, + Vector3, + type Vector3Tuple, +} from 'three'; + +import { type VisScaleType } from '../models'; +import { createScale, getTickFormatter } from '../utils'; +import TextSprite, { + type LabelUserData, + updateLabelSprite, +} from './TextSprite'; +import { getAxisTicks } from './utils'; + +interface AxisConfig { + domain: Domain; + isIndexAxis: boolean; + label?: string; + flip?: boolean; +} + +interface Props { + footprint: [number, number]; + zHeight: number; + abscissaConfig: AxisConfig; + ordinateConfig: AxisConfig; + valueDomain: Domain; + valueLabel?: string; + scaleType: VisScaleType; + showGrid: boolean; +} + +interface Theme { + tickLabels: string; + axisLabels: string; + ticks: string; + grid: string; + gridOpacity: number; + tickFontFamily: string; + axisFontFamily: string; + axisFontWeight: string; +} + +const TICK_LENGTH = 0.025; +const TICK_LABEL_OFFSET = 0.04; // labels are anchored so they extend away from the box +const AXIS_LABEL_OFFSET = 0.16; +const Z_LABEL_OFFSET = 0.05; // along both X and Y, i.e. diagonally away from the corner +const TICK_FONT_SIZE = 11; +const LABEL_FONT_SIZE = 13; +const MIN_Z_EXTENT = 0.05; + +function readTheme(elem: HTMLElement): Theme { + const style = getComputedStyle(elem); + function get(name: string, fallback: string, isColor = false): string { + const value = style.getPropertyValue(name).trim(); + return value && (!isColor || CSS.supports('color', value)) + ? value + : fallback; + } + + const gridOpacity = Number.parseFloat(get('--h5w-grid--opacity', '')); + const fontFamily = style.fontFamily || 'sans-serif'; + + return { + tickLabels: get('--h5w-tickLabels--color', '#333', true), + axisLabels: get('--h5w-axisLabels--color', '#333', true), + ticks: get('--h5w-ticks--color', 'gray', true), + grid: get('--h5w-grid--color', 'gray', true), + gridOpacity: Number.isFinite(gridOpacity) ? gridOpacity : 0.33, + tickFontFamily: get('--h5w-tickLabels--fontFamily', fontFamily), + axisFontFamily: get('--h5w-axisLabels--fontFamily', fontFamily), + axisFontWeight: get('--h5w-axisLabels--fontWeight', '600'), + }; +} + +function useLineGeometry(points: number[]): BufferGeometry { + const geometry = useMemo(() => { + const geom = new BufferGeometry(); + geom.setAttribute( + 'position', + new BufferAttribute(Float32Array.from(points), 3), + ); + return geom; + }, [points]); + + useEffect(() => () => geometry.dispose(), [geometry]); + return geometry; +} + +/** Which side of the box the camera is on, along X and Y (`1` or `-1`). */ +function useCameraSides(): [number, number] { + const [sides, setSides] = useState<[number, number]>([-1, -1]); + const sidesRef = useRef(sides); + + useFrame(({ camera }) => { + const sx = camera.position.x >= 0 ? 1 : -1; + const sy = camera.position.y >= 0 ? 1 : -1; + if (sx !== sidesRef.current[0] || sy !== sidesRef.current[1]) { + sidesRef.current = [sx, sy]; + setSides([sx, sy]); + } + }); + + return sides; +} + +interface Rect { + left: number; + right: number; + bottom: number; + top: number; +} + +const LABEL_PADDING = 2; // px + +const ndc = new Vector3(); + +/** Rectangle of label on screen, or `undefined` if label is behind camera or off-screen. */ +function getScreenRect( + sprite: Sprite, + camera: Camera, + size: Size, +): Rect | undefined { + const { width, height } = sprite.userData as LabelUserData; + sprite.getWorldPosition(ndc).project(camera); + const x = ((ndc.x + 1) / 2) * size.width - sprite.center.x * width; + const y = ((ndc.y + 1) / 2) * size.height - sprite.center.y * height; + + const isOnScreen = + Math.abs(ndc.z) <= 1 && + x + width >= 0 && + x <= size.width && + y + height >= 0 && + y <= size.height; + + return isOnScreen + ? { left: x, right: x + width, bottom: y, top: y + height } + : undefined; +} + +function overlaps(a: Rect, b: Rect): boolean { + return ( + a.left < b.right + LABEL_PADDING && + b.left < a.right + LABEL_PADDING && + a.bottom < b.top + LABEL_PADDING && + b.bottom < a.top + LABEL_PADDING + ); +} + +/** + * Keep labels at constant size on screen and anchored away from the box, and + * hide labels that overlap labels of higher priority on screen (e.g. value-axis labels + * colliding with y-axis labels near the corner they share at low view angles). + */ +function useLabelCollisions(groupRef: RefObject): void { + useFrame(({ camera, size }) => { + const group = groupRef.current; + if (!group) { + return; + } + + const sprites = group.children + .filter((child): child is Sprite => !!child.userData.isLabel) + .sort( + (a, b) => + (b.userData.priority as number) - (a.userData.priority as number), + ); + + const shown: Rect[] = []; + for (const sprite of sprites) { + updateLabelSprite(sprite, camera, size); + const rect = getScreenRect(sprite, camera, size); + sprite.visible = !rect || !shown.some((other) => overlaps(rect, other)); + if (rect && sprite.visible) { + shown.push(rect); + } + } + }); +} + +interface Label { + key: string; + text: string; + position: Vector3Tuple; + outward: Vector3Tuple; // direction away from the box, to anchor the label + priority: number; + isAxisLabel?: boolean; +} + +const PRIORITY = { axisLabel: 3, tick: 2, valueTick: 1 }; + +function SurfaceAxes(props: Props) { + const { + footprint, + zHeight, + abscissaConfig, + ordinateConfig, + valueDomain, + valueLabel, + scaleType, + showGrid, + } = props; + + const [w, h] = footprint; + const domElement = useThree((state) => state.gl.domElement); + const theme = useMemo(() => readTheme(domElement), [domElement]); + + const [sx, sy] = useCameraSides(); + + const groupRef = useRef(null); + useLabelCollisions(groupRef); + + const { axisPoints, gridPoints, labels } = useMemo(() => { + const xScale = createScale(ScaleType.Linear, { + domain: abscissaConfig.domain, + range: abscissaConfig.flip ? [w / 2, -w / 2] : [-w / 2, w / 2], + }); + const yScale = createScale(ScaleType.Linear, { + domain: ordinateConfig.domain, + range: ordinateConfig.flip ? [h / 2, -h / 2] : [-h / 2, h / 2], + }); + const zScale = createScale(scaleType, { + domain: valueDomain, + range: [0, zHeight], + }); + + const xTicks = getAxisTicks( + abscissaConfig.domain, + Math.max(Math.round(w * 6), 2), + abscissaConfig.isIndexAxis, + ); + const yTicks = getAxisTicks( + ordinateConfig.domain, + Math.max(Math.round(h * 6), 2), + ordinateConfig.isIndexAxis, + ); + + const zFormatter = getTickFormatter( + valueDomain, + 300, + Array.isArray(scaleType) ? scaleType[0] : scaleType, + ); + const showZ = zHeight >= MIN_Z_EXTENT; // z ticks would collapse on a flat surface + const zTicks = showZ + ? zScale + .ticks(Math.max(Math.round(zHeight * 8), 2)) + .filter((val) => zFormatter(val) !== '') + : []; + + const xNear = (sx * w) / 2; + const xFar = -xNear; + const yNear = (sy * h) / 2; + const yFar = -yNear; + + const axis: number[] = []; + const grid: number[] = []; + const texts: Label[] = []; + + function seg(arr: number[], a: Vector3Tuple, b: Vector3Tuple): void { + arr.push(...a, ...b); + } + + // Floor outline + seg(axis, [-w / 2, -h / 2, 0], [w / 2, -h / 2, 0]); + seg(axis, [w / 2, -h / 2, 0], [w / 2, h / 2, 0]); + seg(axis, [w / 2, h / 2, 0], [-w / 2, h / 2, 0]); + seg(axis, [-w / 2, h / 2, 0], [-w / 2, -h / 2, 0]); + + // Back walls' vertical edges and z axis + seg(axis, [xNear, yFar, 0], [xNear, yFar, zHeight]); + seg(grid, [xFar, yFar, 0], [xFar, yFar, zHeight]); + seg(grid, [xFar, yNear, 0], [xFar, yNear, zHeight]); + + xTicks.forEach((val) => { + const x = xScale(val); + seg(axis, [x, yNear, 0], [x, yNear + sy * TICK_LENGTH, 0]); + if (showGrid) { + seg(grid, [x, yNear, 0], [x, yFar, 0]); + seg(grid, [x, yFar, 0], [x, yFar, zHeight]); + } + texts.push({ + key: `x${val}`, + text: formatTick(val), + position: [x, yNear + sy * TICK_LABEL_OFFSET, 0], + outward: [0, sy, 0], + priority: PRIORITY.tick, + }); + }); + + yTicks.forEach((val) => { + const y = yScale(val); + seg(axis, [xNear, y, 0], [xNear + sx * TICK_LENGTH, y, 0]); + if (showGrid) { + seg(grid, [xNear, y, 0], [xFar, y, 0]); + seg(grid, [xFar, y, 0], [xFar, y, zHeight]); + } + texts.push({ + key: `y${val}`, + text: formatTick(val), + position: [xNear + sx * TICK_LABEL_OFFSET, y, 0], + outward: [sx, 0, 0], + priority: PRIORITY.tick, + }); + }); + + zTicks.forEach((val) => { + const z = zScale(val); + seg(axis, [xNear, yFar, z], [xNear, yFar - sy * TICK_LENGTH, z]); + if (showGrid) { + seg(grid, [xNear, yFar, z], [xFar, yFar, z]); + seg(grid, [xFar, yFar, z], [xFar, yNear, z]); + } + texts.push({ + key: `z${val}`, + text: formatTick(val), + position: [xNear + sx * Z_LABEL_OFFSET, yFar - sy * Z_LABEL_OFFSET, z], + outward: [sx, -sy, 0], // diagonally away from the corner, and from the surface + priority: PRIORITY.valueTick, + }); + }); + + texts.push( + { + key: 'x-label', + text: abscissaConfig.label ?? '', + position: [0, yNear + sy * AXIS_LABEL_OFFSET, 0], + outward: [0, sy, 0], + priority: PRIORITY.axisLabel, + isAxisLabel: true, + }, + { + key: 'y-label', + text: ordinateConfig.label ?? '', + position: [xNear + sx * AXIS_LABEL_OFFSET, 0, 0], + outward: [sx, 0, 0], + priority: PRIORITY.axisLabel, + isAxisLabel: true, + }, + ); + + if (valueLabel && showZ) { + texts.push({ + key: 'z-label', + text: valueLabel, + position: [xNear, yFar, zHeight + TICK_LABEL_OFFSET], + outward: [0, 0, 1], + priority: PRIORITY.axisLabel, + isAxisLabel: true, + }); + } + + return { + axisPoints: axis, + gridPoints: grid, + labels: texts.filter(({ text }) => text), // no placeholder for missing labels + }; + }, [ + w, + h, + zHeight, + sx, + sy, + abscissaConfig, + ordinateConfig, + valueDomain, + valueLabel, + scaleType, + showGrid, + ]); + + const axisGeometry = useLineGeometry(axisPoints); + const gridGeometry = useLineGeometry(gridPoints); + + return ( + + + + + + + + {labels.map(({ key, text, position, outward, priority, isAxisLabel }) => ( + + ))} + + ); +} + +export type { AxisConfig as SurfaceAxisConfig }; +export default SurfaceAxes; 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..f8a88c8c8 100644 --- a/packages/lib/src/vis/surface/SurfaceVis.tsx +++ b/packages/lib/src/vis/surface/SurfaceVis.tsx @@ -1,58 +1,211 @@ 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 SurfaceAxes, { type SurfaceAxisConfig } from './SurfaceAxes'; +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; + valueLabel?: string; + flipXAxis?: boolean; + flipYAxis?: boolean; + keepRatio?: boolean; + heightScale?: number; + showAxes?: boolean; + showGrid?: boolean; + 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 = {}, + valueLabel, + flipXAxis = false, + flipYAxis = false, + keepRatio = true, + heightScale = 0.5, + showAxes = true, + showGrid = true, + 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 { label: abscissaLabel, value: abscissaValue } = abscissaParams; + const { label: ordinateLabel, 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 abscissaConfig = useMemo( + () => ({ + domain: xDomain, + isIndexAxis: !abscissaValue || abscissaValue.length < cols, // see `getCellCenters` + label: abscissaLabel, + flip: flipXAxis, + }), + [xDomain, abscissaValue, cols, abscissaLabel, flipXAxis], + ); + + const ordinateConfig = useMemo( + () => ({ + domain: yDomain, + isIndexAxis: !ordinateValue || ordinateValue.length < rows, + label: ordinateLabel, + flip: flipYAxis, + }), + [yDomain, ordinateValue, rows, ordinateLabel, flipYAxis], + ); + + const resetRef = useRef<() => void>(undefined); + return (
- - - {children} - +
+ {title &&

{title}

} +
+ + + + {showAxes && ( + + )} + {children} + + +
+
0 && Number.isFinite(length)) { + sprite.center.set(0.5 - (0.5 * dx) / length, 0.5 - (0.5 * dy) / length); + } +} + +/** + * Text that always faces the camera (billboard) and keeps a constant size on screen. + * Rendered to a canvas texture to avoid depending on a font-loading library. + */ +function TextSprite(props: Props) { + const { + text, + position, + outward, + color, + fontSize, + fontWeight = 400, + fontFamily, + priority = 0, + } = props; + + const dpr = useThree((state) => state.viewport.dpr); + + const { texture, width, height } = useMemo(() => { + const font = `${fontWeight} ${fontSize}px ${fontFamily}`; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (ctx) { + ctx.font = font; + } + + const textWidth = ctx?.measureText(text).width ?? text.length * fontSize; + const cssWidth = Math.ceil(textWidth) + PADDING * 2; + const cssHeight = Math.ceil(fontSize * 1.3) + PADDING * 2; + + canvas.width = Math.max(cssWidth * dpr, 1); + canvas.height = Math.max(cssHeight * dpr, 1); + + if (ctx) { + ctx.scale(dpr, dpr); + ctx.font = font; // resizing the canvas resets the context + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, cssWidth / 2, cssHeight / 2); + } + + const tex = new CanvasTexture(canvas); + tex.colorSpace = SRGBColorSpace; + tex.minFilter = LinearFilter; + tex.generateMipmaps = false; + + return { texture: tex, width: cssWidth, height: cssHeight }; + }, [text, color, fontSize, fontWeight, fontFamily, dpr]); + + useEffect(() => () => texture.dispose(), [texture]); + + return ( + + + + ); +} + +export default TextSprite; diff --git a/packages/lib/src/vis/surface/surfaceMeshGeometry.test.ts b/packages/lib/src/vis/surface/surfaceMeshGeometry.test.ts new file mode 100644 index 000000000..0ffb44bca --- /dev/null +++ b/packages/lib/src/vis/surface/surfaceMeshGeometry.test.ts @@ -0,0 +1,127 @@ +import { assertNonNull } from '@h5web/shared/guards'; +import { ScaleType } from '@h5web/shared/vis-models'; +import ndarray from 'ndarray'; +import { describe, expect, it } from 'vitest'; + +import SurfaceMeshGeometry from './surfaceMeshGeometry'; +import { getLinearColorLut } from './utils'; + +function buildGeometry( + params: ConstructorParameters[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..0165ee294 --- /dev/null +++ b/packages/lib/src/vis/surface/utils.test.ts @@ -0,0 +1,158 @@ +import ndarray from 'ndarray'; +import { describe, expect, it } from 'vitest'; + +import { + getAxisTicks, + 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('getAxisTicks', () => { + it('should return nice ticks within domain', () => { + expect(getAxisTicks([0, 1], 5, false)).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1]); + expect(getAxisTicks([-18, 21], 4, false)).toEqual([-10, 0, 10, 20]); + }); + + it('should support descending domains', () => { + expect(getAxisTicks([21, -18], 4, false)).toEqual([20, 10, 0, -10]); + }); + + it('should return integer ticks for index axes', () => { + expect(getAxisTicks([0, 3], 10, true)).toEqual([0, 1, 2, 3]); + }); +}); + +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..669ffc4f4 --- /dev/null +++ b/packages/lib/src/vis/surface/utils.ts @@ -0,0 +1,197 @@ +import { + type Domain, + type NumArray, + ScaleType, +} 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 { createScale, getAxisDomain, getIntegerTicks } 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]; +} + +/** Nice ticks within domain (ascending or descending); integer ticks only for axes of indices. */ +export function getAxisTicks( + domain: Domain, + count: number, + isIndexAxis: boolean, +): number[] { + if (isIndexAxis) { + return getIntegerTicks(domain, count); + } + + return createScale(ScaleType.Linear, { domain }).ticks(count); +} + +/** 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); +}