diff --git a/src/GlobalStates/GlobalStore.ts b/src/GlobalStates/GlobalStore.ts index bea8ab47c..d80a5e652 100644 --- a/src/GlobalStates/GlobalStore.ts +++ b/src/GlobalStates/GlobalStore.ts @@ -17,9 +17,12 @@ interface Coord { type StoreState = { dataShape: number[]; + activeIndices: number[]; shape: THREE.Vector3; valueScales: { maxVal: number; minVal: number }; colormap: THREE.DataTexture; + colormapName: string; + flipColormap: boolean; timeSeries: Record>; strides: number[]; metadata: Record | null; @@ -50,9 +53,12 @@ type StoreState = { // setters setDataShape: (dataShape: number[]) => void; + setActiveIndices: (indices: number[]) => void; setShape: (shape: THREE.Vector3) => void; setValueScales: (valueScales: { maxVal: number; minVal: number }) => void; setColormap: (colormap: THREE.DataTexture) => void; + setColormapName: (colormapName: string) => void; + setFlipColormap: (flipColormap: boolean) => void; setTimeSeries: (timeSeries: Record>) => void; updateTimeSeries: (newEntries: Record>) => void; setStrides: (strides: number[]) => void; @@ -86,9 +92,12 @@ type StoreState = { export const useGlobalStore = create((set, get) => ({ dataShape: [1, 1, 1], + activeIndices: [], shape: new THREE.Vector3(2, 2, 2), valueScales: { maxVal: 1, minVal: -1 }, colormap: GetColorMapTexture(), + colormapName: "Spectral", + flipColormap: false, timeSeries: {}, strides: [10368,144,1], metadata: null, @@ -119,9 +128,12 @@ export const useGlobalStore = create((set, get) => ({ // setters setDataShape: (dataShape) => set({ dataShape }), + setActiveIndices: (indices) => set({ activeIndices: indices }), setShape: (shape) => set({ shape }), setValueScales: (valueScales) => set({ valueScales }), setColormap: (colormap) => set({ colormap }), + setColormapName: (colormapName) => set({ colormapName }), + setFlipColormap: (flipColormap) => set({ flipColormap }), setTimeSeries: (timeSeries) => set({ timeSeries }), updateTimeSeries: (newEntries) => { const merged = { ...newEntries, ...get().timeSeries }; diff --git a/src/GlobalStates/ZarrStore.ts b/src/GlobalStates/ZarrStore.ts index 423796dfa..875f3e378 100644 --- a/src/GlobalStates/ZarrStore.ts +++ b/src/GlobalStates/ZarrStore.ts @@ -1,5 +1,4 @@ import { create } from "zustand"; -import { GetStore } from "@/components/zarr/ZarrLoaderLRU"; import { FetchStoreOptions, IcechunkStoreOptions } from "@/components/zarr/Interfaces"; const ESDC = 'https://s3.bgc-jena.mpg.de:9000/esdl-esdc-v3.0.2/esdc-16d-2.5deg-46x72x1440-3.0.2.zarr' @@ -25,9 +24,14 @@ type ZarrState = { fetchKey: number; blobKey: string | undefined; // The key for the stored File blob for a local NC + ndSlices: (number | [number, number | null])[]; + axisMapping: { x: number, y: number, z: number }; + setZSlice: (zSlice: [number , number | null]) => void; setYSlice: (ySlice: [number , number | null]) => void; setXSlice: (xSlice: [number , number | null]) => void; + setNdSlices: (ndSlices: (number | [number, number | null])[]) => void; + setAxisMapping: (mapping: { x: number, y: number, z: number }) => void; setCompress: (compress: boolean) => void; setCurrentStore: (currentStore: any) => void; setReFetch: (reFetch: boolean) => void; @@ -49,8 +53,10 @@ export const useZarrStore = create((set, get) => ({ zSlice: [0, null], ySlice: [0, null], xSlice: [0, null], + ndSlices: [], + axisMapping: { x: -1, y: -1, z: -1 }, compress: false, - currentStore: GetStore(ESDC), + currentStore: Promise.resolve(undefined), reFetch: false, currentChunks: {x:[], y:[], z:[]}, arraySize: 0, @@ -69,6 +75,8 @@ export const useZarrStore = create((set, get) => ({ setZSlice: (zSlice) => set({ zSlice }), setYSlice: (ySlice) => set({ ySlice }), setXSlice: (xSlice) => set({ xSlice }), + setNdSlices: (ndSlices) => set({ ndSlices }), + setAxisMapping: (axisMapping) => set({ axisMapping }), setCompress: (compress) => set({ compress }), setCurrentStore: (currentStore) => set({ currentStore }), setReFetch: (reFetch) => set({ reFetch }), diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 1be217870..b3062afd8 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -104,8 +104,9 @@ beforeAll(() => { COPY_DST: 2, TEXTURE_BINDING: 4, STORAGE_BINDING: 8, - RENDER_ATTACHMENT: 16 - } + RENDER_ATTACHMENT: 16, + TRANSIENT_ATTACHMENT: 32 + } as GPUTextureUsage }) // Export helper to set mock GPU results diff --git a/src/__tests__/webGPU-nan.test.ts b/src/__tests__/webGPU-nan.test.ts new file mode 100644 index 000000000..8922a0474 --- /dev/null +++ b/src/__tests__/webGPU-nan.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, test, beforeAll } from 'vitest' +import { DataReduction, Convolve, Multivariate2D, Multivariate3D, CUMSUM3D, Convolve2D } from '../components/computation/webGPU' +import { setMockGPUResult } from './setup' + +describe('WebGPU Functions - NaN Statistics', () => { + beforeAll(async () => { + if (!navigator.gpu) { + console.warn('WebGPU is not supported in this environment') + return + } + + const adapter = await navigator.gpu.requestAdapter() + if (!adapter) { + console.warn('No WebGPU adapter found') + return + } + + const device = await adapter.requestDevice() + if (!device) { + console.warn('No WebGPU device found') + return + } + }) + + // Array layout (3x3x1) with NaNs: + // [1, NaN, 3] + // [4, 5, NaN] + // [NaN, 8, 9] + const arrayWithNaN = new Float32Array([1, NaN, 3, 4, 5, NaN, NaN, 8, 9]) + + // All NaN array + const allNaNArray = new Float32Array([NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]) + + const shape3D = [3, 3, 1] + const strides3D = [3, 1, 1] + const shape2D = [3, 3] + const strides2D = [3, 1] + + describe('DataReduction with NaNs', () => { + test('Mean Reduction - skips NaNs', async () => { + // Reducing along dim 0 (mean of each column): + // Col 0: (1+4)/2 = 2.5 (skips NaN) + // Col 1: (5+8)/2 = 6.5 (skips NaN) + // Col 2: (3+9)/2 = 6 (skips NaN) + setMockGPUResult([2.5, 6.5, 6]) + const result = await DataReduction(arrayWithNaN, { shape: shape3D, strides: strides3D }, 0, 'Mean') + expect(result).toBeDefined() + expect(Array.from(result!)).toEqual([2.5, 6.5, 6]) + }) + + test('Min Reduction - skips NaNs', async () => { + // Reducing along dim 0 (min of each column): + // Col 0: min(1, 4) = 1 + // Col 1: min(5, 8) = 5 + // Col 2: min(3, 9) = 3 + setMockGPUResult([1, 5, 3]) + const result = await DataReduction(arrayWithNaN, { shape: shape3D, strides: strides3D }, 0, 'Min') + expect(result).toBeDefined() + expect(Array.from(result!)).toEqual([1, 5, 3]) + }) + + test('Max Reduction - skips NaNs', async () => { + // Reducing along dim 0 (max of each column): + // Col 0: max(1, 4) = 4 + // Col 1: max(5, 8) = 8 + // Col 2: max(3, 9) = 9 + setMockGPUResult([4, 8, 9]) + const result = await DataReduction(arrayWithNaN, { shape: shape3D, strides: strides3D }, 0, 'Max') + expect(result).toBeDefined() + expect(Array.from(result!)).toEqual([4, 8, 9]) + }) + + test('Mean Reduction - all NaNs returns NaN', async () => { + // If all are NaN, mean should be NaN + setMockGPUResult([NaN, NaN, NaN]) + const result = await DataReduction(allNaNArray, { shape: shape3D, strides: strides3D }, 0, 'Mean') + expect(result).toBeDefined() + expect(Number.isNaN(result![0])).toBe(true) + expect(Number.isNaN(result![1])).toBe(true) + expect(Number.isNaN(result![2])).toBe(true) + }) + }) + + describe('Convolution with NaNs', () => { + test('3D Mean Convolution - ignores NaNs', async () => { + // For the arrayWithNaN [1, NaN, 3, 4, 5, NaN, NaN, 8, 9] with 3x3 kernel + // The kernel should only sum valid pixels and divide by valid N. + // E.g., center pixel (5) surrounded by 1, NaN, 3, 4, NaN, NaN, 8, 9 + // Sum = 1+3+4+5+8+9 = 30. Valid N = 6. 30/6 = 5.0 + // We'll mock out this expected result. + const expectedValues = [1.0, 3.25, 3.0, 2.5, 5.0, 6.0, 4.0, 6.5, 9.0] + setMockGPUResult(expectedValues) + const result = await Convolve( + arrayWithNaN, + { shape: shape3D, strides: strides3D }, + 'Mean3D', + { kernelSize: 3, kernelDepth: 1 } + ) + expect(result).toBeDefined() + expect(result?.length).toBe(9) + result!.forEach((val, idx) => { + expect(val).toBeCloseTo(expectedValues[idx], 1) + }) + }) + + test('3D Mean Convolution - all NaNs returns NaN', async () => { + const expectedValues = new Float32Array([NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]) + setMockGPUResult(Array.from(expectedValues)) + const result = await Convolve( + allNaNArray, + { shape: shape3D, strides: strides3D }, + 'Mean3D', + { kernelSize: 3, kernelDepth: 1 } + ) + expect(result).toBeDefined() + expect(result?.length).toBe(9) + result!.forEach(val => { + expect(Number.isNaN(val)).toBe(true) + }) + }) + + test('2D Mean Convolution - ignores NaNs', async () => { + // Same logic as 3D Convolution + const expectedValues = [1.0, 3.25, 3.0, 2.5, 5.0, 6.0, 4.0, 6.5, 9.0] + setMockGPUResult(expectedValues) + const result = await Convolve2D( + arrayWithNaN, + { shape: shape2D, strides: strides2D }, + 'Mean2D', + 3 + ) + expect(result).toBeDefined() + expect(result?.length).toBe(9) + result!.forEach((val, idx) => { + expect(val).toBeCloseTo(expectedValues[idx], 1) + }) + }) + }) + + describe('Multivariate with NaNs', () => { + // Array 1 valid elements are at indices: 0, 2, 3, 4, 7, 8 + // [1, NaN, 3, 4, 5, NaN, NaN, 8, 9] + // [9, 8, 7, 6, 5, 4, 3, 2, 1] + const secondArray = new Float32Array([9, 8, 7, 6, 5, 4, 3, 2, 1]) + + test('2D Correlation - computes only on pairwise valid elements', async () => { + // Pairwise valid elements: + // Array 1 valid: [1, 3, 4, 5, 8, 9] + // Array 2 valid: [9, 7, 6, 5, 2, 1] + // This is a perfect negative correlation (-1) + setMockGPUResult([-1]) + const result = await Multivariate2D( + arrayWithNaN, + secondArray, + { shape: shape3D, strides: strides3D }, + 0, + 'Correlation2D' + ) + expect(result).toBeDefined() + expect(result?.length).toBe(1) + expect(result![0]).toBeCloseTo(-1, 5) + }) + + test('3D Correlation - computes only on pairwise valid elements', async () => { + setMockGPUResult([-1]) + const result = await Multivariate3D( + arrayWithNaN, + secondArray, + { shape: shape3D, strides: strides3D }, + { kernelSize: 3, kernelDepth: 1 }, + 'Correlation3D' + ) + expect(result).toBeDefined() + expect(result?.length).toBe(1) + expect(result![0]).toBeCloseTo(-1, 5) + }) + }) + + describe('CUMSUM3D with NaNs', () => { + test('Basic CUMSUM operation along dimension 0', async () => { + // Array layout (3x3x1): + // [1, NaN, 3] + // [4, 5, NaN] + // [NaN, 8, 9] + // + // Cumsum along dim 0 (exclusive accumulation down rows for each column): + // Col 0 [1, 4, NaN]: + // z=0: 0.0 (first valid element) + // z=1: 1.0 (accumulated 1) + // z=2: 5.0 (accumulated 1+4) + // Col 1 [NaN, 5, 8]: + // z=0: NaN (no valid elements seen yet, current is NaN) + // z=1: 0.0 (first valid element) + // z=2: 5.0 (accumulated 5) + // Col 2 [3, NaN, 9]: + // z=0: 0.0 (first valid element) + // z=1: 3.0 (accumulated 3, current is NaN so it outputs accum) + // z=2: 3.0 (accumulated 3) + // Expected: [0, NaN, 0, 1, 0, 3, 5, 5, 3] + const expected = [0, NaN, 0, 1, 0, 3, 5, 5, 3] + setMockGPUResult(expected) + const result = await CUMSUM3D( + arrayWithNaN, + { shape: shape3D, strides: strides3D }, + 0, + 0 + ) + expect(result).toBeDefined() + result!.forEach((val, i) => { + if (Number.isNaN(expected[i])) { + expect(Number.isNaN(val)).toBe(true) + } else { + expect(val).toBe(expected[i]) + } + }) + }) + + test('Reverse CUMSUM operation along dimension 0', async () => { + // Reverse cumsum along dim 0 (accumulate up rows for each column): + // Col 0 [1, 4, NaN]: [4, NaN, NaN] + // Col 1 [NaN, 5, 8]: [13, 8, 0] + // Col 2 [3, NaN, 9]: [9, 9, 0] + const expected = [4, 13, 9, NaN, 8, 9, NaN, 0, 0] + setMockGPUResult(expected) + const result = await CUMSUM3D( + arrayWithNaN, + { shape: shape3D, strides: strides3D }, + 0, + 1 + ) + expect(result).toBeDefined() + result!.forEach((val, i) => { + if (Number.isNaN(expected[i])) { + expect(Number.isNaN(val)).toBe(true) + } else { + expect(val).toBe(expected[i]) + } + }) + }) + + test('Basic CUMSUM operation along dimension 1', async () => { + // Cumsum along dim 1 (accumulate across columns within each row): + // Row 0 [1, NaN, 3]: [0, 1, 1] + // Row 1 [4, 5, NaN]: [0, 4, 9] + // Row 2 [NaN, 8, 9]: [NaN, 0, 8] + const expected = [0, 1, 1, 0, 4, 9, NaN, 0, 8] + setMockGPUResult(expected) + const result = await CUMSUM3D( + arrayWithNaN, + { shape: shape3D, strides: strides3D }, + 1, + 0 + ) + expect(result).toBeDefined() + result!.forEach((val, i) => { + if (Number.isNaN(expected[i])) { + expect(Number.isNaN(val)).toBe(true) + } else { + expect(val).toBe(expected[i]) + } + }) + }) + + test('CUMSUM - all NaNs returns NaN for all', async () => { + // For all NaNs, validCount remains 0 and every element is NaN, so output is all NaNs + const expected = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN] + setMockGPUResult(expected) + const result = await CUMSUM3D( + allNaNArray, + { shape: shape3D, strides: strides3D }, + 0, + 0 + ) + expect(result).toBeDefined() + result!.forEach(val => { + expect(Number.isNaN(val)).toBe(true) + }) + }) + }) +}) diff --git a/src/components/computation/WGSLShaders.ts b/src/components/computation/WGSLShaders.ts index c890fcaf9..6adfb0348 100644 --- a/src/components/computation/WGSLShaders.ts +++ b/src/components/computation/WGSLShaders.ts @@ -151,6 +151,7 @@ export const createShaders = (precision: Precision) => { MeanReduction: /* wgsl */` ${ReductionBoilerPlate} var sum: f32 = 0.0; + var nanCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z @@ -159,6 +160,7 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (z * zStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; @@ -169,6 +171,7 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (y * yStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; @@ -179,6 +182,7 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (x * xStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; @@ -186,13 +190,15 @@ export const createShaders = (precision: Precision) => { } let outputIndex = outY * xSize + outX; - outputData[outputIndex] = ${precision}(sum / f32(dimLength)); + let N = f32(dimLength - nanCount); + outputData[outputIndex] = ${precision}(sum / N); } `, MinReduction: /* wgsl */` ${ReductionBoilerPlate} var min: f32 = 1e12; + var nanCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z @@ -200,6 +206,7 @@ export const createShaders = (precision: Precision) => { for (var z: u32 = 0u; z < dimLength; z++) { let inputIndex = cCoord + (z * zStride); let newMin = f32(inputData[inputIndex]); + if (isNaN(newMin)) { nanCount++; continue; } if (newMin < min) { min = newMin; } @@ -209,6 +216,7 @@ export const createShaders = (precision: Precision) => { for (var y: u32 = 0u; y < dimLength; y++) { let inputIndex = cCoord + (y * yStride); let newMin = f32(inputData[inputIndex]); + if (isNaN(newMin)) { nanCount++; continue; } if (newMin < min) { min = newMin; } @@ -218,6 +226,7 @@ export const createShaders = (precision: Precision) => { for (var x: u32 = 0u; x < dimLength; x++) { let inputIndex = cCoord + (x * xStride); let newMin = f32(inputData[inputIndex]); + if (isNaN(newMin)) { nanCount++; continue; } if (newMin < min) { min = newMin; } @@ -225,7 +234,12 @@ export const createShaders = (precision: Precision) => { } let outputIndex = outY * xSize + outX; - outputData[outputIndex] = ${precision}(min); + if (nanCount == dimLength) { + let zero = 0.0; + outputData[outputIndex] = ${precision}(zero / zero); + } else { + outputData[outputIndex] = ${precision}(min); + } } `, @@ -233,6 +247,7 @@ export const createShaders = (precision: Precision) => { ${ReductionBoilerPlate} var max: f32 = -1e12; + var nanCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z @@ -240,6 +255,7 @@ export const createShaders = (precision: Precision) => { for (var z: u32 = 0u; z < dimLength; z++) { let inputIndex = cCoord + (z * zStride); let newMax = f32(inputData[inputIndex]); + if (isNaN(newMax)) { nanCount++; continue; } if (newMax > max) { max = newMax; } @@ -249,6 +265,7 @@ export const createShaders = (precision: Precision) => { for (var y: u32 = 0u; y < dimLength; y++) { let inputIndex = cCoord + (y * yStride); let newMax = f32(inputData[inputIndex]); + if (isNaN(newMax)) { nanCount++; continue; } if (newMax > max) { max = newMax; } @@ -258,6 +275,7 @@ export const createShaders = (precision: Precision) => { for (var x: u32 = 0u; x < dimLength; x++) { let inputIndex = cCoord + (x * xStride); let newMax = f32(inputData[inputIndex]); + if (isNaN(newMax)) { nanCount++; continue; } if (newMax > max) { max = newMax; } @@ -265,13 +283,19 @@ export const createShaders = (precision: Precision) => { } let outputIndex = outY * xSize + outX; - outputData[outputIndex] = ${precision}(max); + if (nanCount == dimLength) { + let zero = 0.0; + outputData[outputIndex] = ${precision}(zero / zero); + } else { + outputData[outputIndex] = ${precision}(max); + } } `, StDevReduction: /* wgsl */` ${ReductionBoilerPlate} var sum: f32 = 0.0; + var nanCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z let cCoord = outX * xStride + outY * yStride; @@ -279,6 +303,7 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (z * zStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; @@ -289,6 +314,7 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (y * yStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; @@ -299,13 +325,15 @@ export const createShaders = (precision: Precision) => { let inputIndex = cCoord + (x * xStride); let newVal = f32(inputData[u32(inputIndex)]); if (isNaN(newVal)){ + nanCount++; continue; } sum += newVal; } } - let mean: f32 = sum / f32(dimLength); + let N: f32 = f32(dimLength - nanCount); + let mean: f32 = sum / N; var squaredDiffSum: f32 = 0.0; @@ -345,7 +373,7 @@ export const createShaders = (precision: Precision) => { } } - let stDev: f32 = sqrt(squaredDiffSum / f32(dimLength)); + let stDev: f32 = sqrt(squaredDiffSum / N); let outputIndex = outY * xSize + outX; outputData[outputIndex] = ${precision}(stDev); } @@ -415,30 +443,44 @@ export const createShaders = (precision: Precision) => { ${ReductionBoilerPlate} let meanY: f32 = f32(dimLength)/2; var sum: f32 = 0.0; - + var nanCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // Average along Z let cCoord = outX * xStride + outY * yStride; for (var z: u32 = 0u; z < dimLength; z++) { let inputIndex = cCoord + (z * zStride); - sum += f32(inputData[inputIndex]); + let val = f32(inputData[inputIndex]); + if (isNaN(val)) { nanCount++; continue; } + sum += val; } } else if (reduceDim == 1u) { // Average along Y let cCoord = outX * xStride + outY * zStride; for (var y: u32 = 0u; y < dimLength; y++) { let inputIndex = cCoord + (y * yStride); - sum += f32(inputData[inputIndex]); + let val = f32(inputData[inputIndex]); + if (isNaN(val)) { nanCount++; continue; } + sum += val; } } else { // Average along X let cCoord = outX * yStride + outY * zStride; for (var x: u32 = 0u; x < dimLength; x++) { let inputIndex = cCoord + (x * xStride); - sum += f32(inputData[inputIndex]); + let val = f32(inputData[inputIndex]); + if (isNaN(val)) { nanCount++; continue; } + sum += val; } } - let meanX: f32 = sum / f32(dimLength); + let N: f32 = f32(dimLength - nanCount); + if (N <= 1.0) { + let zero = 0.0; + let outputIndex = outY * xSize + outX; + outputData[outputIndex] = ${precision}(zero / zero); + return; + } + + let meanX: f32 = sum / N; var numSum: f32 = 0; var denomSum: f32 = 0; @@ -447,6 +489,7 @@ export const createShaders = (precision: Precision) => { for (var z: u32 = 0u; z < dimLength; z++) { let inputIndex = cCoord + (z * zStride); let xi: f32 = f32(inputData[inputIndex]); + if (isNaN(xi)) { continue; } numSum += (xi - meanX)*(f32(z) - meanY); denomSum += (f32(z) - meanY)*(f32(z) - meanY); } @@ -455,6 +498,7 @@ export const createShaders = (precision: Precision) => { for (var y: u32 = 0u; y < dimLength; y++) { let inputIndex = cCoord + (y * yStride); let xi: f32 = f32(inputData[inputIndex]); + if (isNaN(xi)) { continue; } numSum += (xi - meanX)*(f32(y) - meanY); denomSum += (f32(y) - meanY)*(f32(y) - meanY); } @@ -463,6 +507,7 @@ export const createShaders = (precision: Precision) => { for (var x: u32 = 0u; x < dimLength; x++) { let inputIndex = cCoord + (x * xStride); let xi: f32 = f32(inputData[inputIndex]); + if (isNaN(xi)) { continue; } numSum += (xi - meanX)*(f32(x) - meanY); denomSum += (f32(x) - meanY)*(f32(x) - meanY); } @@ -557,8 +602,16 @@ export const createShaders = (precision: Precision) => { } } - let xMean: f32 = xSum / f32(dimLength - nanCount); - let yMean: f32 = ySum / f32(dimLength - nanCount); + let N: f32 = f32(dimLength - nanCount); + if (N <= 1.0) { + let zero = 0.0; + let outputIndex = outY * xSize + outX; + outputData[outputIndex] = ${precision}(zero / zero); + return; + } + + let xMean: f32 = xSum / N; + let yMean: f32 = ySum / N; var numSum: f32 = 0; var denomSum: f32 = 0; @@ -674,6 +727,12 @@ export const createShaders = (precision: Precision) => { } var N: f32 = f32(dimLength - nanCount); + if (N <= 1.0) { + let zero = 0.0; + let outputIndex = outY * xSize + outX; + outputData[outputIndex] = ${precision}(zero / zero); + return; + } let xMean: f32 = xSum / N; let yMean: f32 = ySum / N; @@ -788,6 +847,12 @@ export const createShaders = (precision: Precision) => { } let N: f32 = f32(dimLength - nanCount); + if (N <= 1.0) { + let zero = 0.0; + let outputIndex = outY * xSize + outX; + outputData[outputIndex] = ${precision}(zero / zero); + return; + } let meanX = xSum / N; let meanY = ySum / N; let varX = (xxSum / N) - (meanX * meanX); @@ -822,7 +887,9 @@ export const createShaders = (precision: Precision) => { let zOffset = kz * i32(zStride); let newIdx = i32(globalIdx) + xOffset + yOffset + zOffset; - sum += f32(inputData[u32(newIdx)]); + let val = f32(inputData[u32(newIdx)]); + if (isNaN(val)) { continue; } + sum += val; count ++; } } @@ -849,15 +916,22 @@ export const createShaders = (precision: Precision) => { let zOffset = kz * i32(zStride); let newIdx = i32(globalIdx) + xOffset + yOffset + zOffset; let sampledVal = f32(inputData[u32(newIdx)]); + if (isNaN(sampledVal)) { continue; } if (sampledVal < minVal){ minVal = sampledVal; } + count++; } } } } - outputData[globalIdx] = ${precision}(minVal); + if (count > 0u) { + outputData[globalIdx] = ${precision}(minVal); + } else { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + } } `, @@ -879,14 +953,21 @@ export const createShaders = (precision: Precision) => { let zOffset = kz * i32(zStride); let newIdx = i32(globalIdx) + xOffset + yOffset + zOffset; let sampledVal = f32(inputData[u32(newIdx)]); + if (isNaN(sampledVal)) { continue; } if (sampledVal > maxVal){ maxVal = sampledVal; } + count++; } } } } - outputData[globalIdx] = ${precision}(maxVal); + if (count > 0u) { + outputData[globalIdx] = ${precision}(maxVal); + } else { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + } } `, @@ -1029,9 +1110,7 @@ export const createShaders = (precision: Precision) => { let xI = f32(firstData[newIdx]); let yI = f32(secondData[newIdx]); - if (isNaN(xI) || isNaN(yI)){ - continue; - } + if (isNaN(xI) || isNaN(yI)) { continue; } xSum += xI; xxSum += xI * xI; ySum += yI; @@ -1044,6 +1123,11 @@ export const createShaders = (precision: Precision) => { } let N: f32 = f32(count); + if (N <= 1.0) { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + return; + } let meanX = xSum / N; let meanY = ySum / N; let varX = (xxSum / N) - (meanX * meanX); @@ -1132,9 +1216,7 @@ export const createShaders = (precision: Precision) => { let newIdx = i32(globalIdx) + xOffset + yOffset + zOffset; let xI = f32(firstData[newIdx]); let yI = f32(secondData[newIdx]); - if (isNaN(xI) || isNaN(yI)){ - continue; - } + if (isNaN(xI) || isNaN(yI)) { continue; } xSum += xI; ySum += yI; count ++; @@ -1144,6 +1226,11 @@ export const createShaders = (precision: Precision) => { } let N: f32 = f32(count); + if (N <= 1.0) { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + return; + } let meanX = xSum / N; let meanY = ySum / N; @@ -1160,11 +1247,8 @@ export const createShaders = (precision: Precision) => { let newIdx = i32(globalIdx) + xOffset + yOffset + zOffset; let xI = f32(firstData[newIdx]); let yI = f32(secondData[newIdx]); - if (isNaN(xI) || isNaN(yI)){ - continue; - } + if (isNaN(xI) || isNaN(yI)) { continue; } numSum += (xI - meanX) * (yI - meanY); - count ++; } } } @@ -1258,6 +1342,11 @@ export const createShaders = (precision: Precision) => { let N: f32 = f32(count); + if (N <= 1.0) { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + return; + } let meanX = xSum / N; let meanY = ySum / N; var numSum: f32 = 0; @@ -1334,10 +1423,16 @@ export const createShaders = (precision: Precision) => { if (newVal < minVal){ minVal = newVal; } + count++; } } } - outputData[globalIdx] = ${precision}(minVal); + if (count > 0u) { + outputData[globalIdx] = ${precision}(minVal); + } else { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + } } `, @@ -1359,10 +1454,16 @@ export const createShaders = (precision: Precision) => { if (newVal > maxVal){ maxVal = newVal; } + count++; } } } - outputData[globalIdx] = ${precision}(maxVal); + if (count > 0u) { + outputData[globalIdx] = ${precision}(maxVal); + } else { + let zero = 0.0; + outputData[globalIdx] = ${precision}(zero / zero); + } } `, @@ -1430,7 +1531,7 @@ export const createShaders = (precision: Precision) => { workGroups: vec3, }; @group(0) @binding(0) var inputData: array<${precision}>; - @group(0) @binding(1) var outputData: array; + @group(0) @binding(1) var outputData: array<${precision}>; @group(0) @binding(2) var params: Params; ${isNaNFunc} @compute @workgroup_size(4, 4, 4) @@ -1455,6 +1556,7 @@ export const createShaders = (precision: Precision) => { let totalSize: u32 = xSize * ySize * zSize; var baseIdx = outZ * zStride + outY * yStride + outX * xStride; var accum: f32 = 0; + var validCount: u32 = 0u; // Iterate along the dimension we're averaging if (reduceDim == 0u) { // CUMSUM along Z @@ -1467,7 +1569,10 @@ export const createShaders = (precision: Precision) => { newZ = zSize - z - 1; } let idx = newZ * zStride + outY * yStride + outX * xStride; - accum += f32(inputData[idx]); + let val = f32(inputData[idx]); + if (isNaN(val)) { continue; } + accum += val; + validCount++; } } else if (reduceDim == 1u) { // CUMSUM along Y @@ -1480,7 +1585,10 @@ export const createShaders = (precision: Precision) => { newY = ySize - y - 1; } let idx = outZ * zStride + newY * yStride + outX * xStride; - accum += f32(inputData[idx]); + let val = f32(inputData[idx]); + if (isNaN(val)) { continue; } + accum += val; + validCount++; } } else { // CUMSUM along X if (reverse == u32(1)){ @@ -1492,12 +1600,21 @@ export const createShaders = (precision: Precision) => { newX = xSize - x - 1; } let idx = outZ * zStride + outY * yStride + newX * xStride; - accum += f32(inputData[idx]); + let val = f32(inputData[idx]); + if (isNaN(val)) { continue; } + accum += val; + validCount++; } } - outputData[baseIdx] = accum; + let current_val = f32(inputData[baseIdx]); + if (validCount == 0u && isNaN(current_val)) { + let zero = 0.0; + outputData[baseIdx] = ${precision}(zero / zero); + } else { + outputData[baseIdx] = ${precision}(accum); + } } ` } return allShaders; -} +} \ No newline at end of file diff --git a/src/components/computation/shaders/frag.glsl b/src/components/computation/shaders/frag.glsl index 7e1fd2d31..527b1fd97 100644 --- a/src/components/computation/shaders/frag.glsl +++ b/src/components/computation/shaders/frag.glsl @@ -4,7 +4,7 @@ uniform sampler2D cmap; uniform float cOffset; uniform float cScale; -varying vec2 vUv; +in vec2 vUv; out vec4 Color; void main() { diff --git a/src/components/computation/shaders/vert.glsl b/src/components/computation/shaders/vert.glsl index 14e9b8a60..6b50cb5f8 100644 --- a/src/components/computation/shaders/vert.glsl +++ b/src/components/computation/shaders/vert.glsl @@ -1,5 +1,5 @@ // by Jeran Poehls -varying vec2 vUv; +out vec2 vUv; void main() { vUv = uv; diff --git a/src/components/computation/webGPU.ts b/src/components/computation/webGPU.ts index fbbfe21da..aa83cd614 100644 --- a/src/components/computation/webGPU.ts +++ b/src/components/computation/webGPU.ts @@ -572,7 +572,7 @@ export async function CUMSUM3D(inputArray : ArrayBufferView, dimInfo : {shape: const outputBuffer = device.createBuffer({ label: 'Output Buffer', - size: outputSize * 4, + size: outputSize * (hasF16 ? 2 : 4), usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC, }); @@ -584,7 +584,7 @@ export async function CUMSUM3D(inputArray : ArrayBufferView, dimInfo : {shape: const readBuffer = device.createBuffer({ label:'Read Buffer', - size: outputSize * 4, + size: outputSize * (hasF16 ? 2 : 4), usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ, }); @@ -616,7 +616,7 @@ export async function CUMSUM3D(inputArray : ArrayBufferView, dimInfo : {shape: encoder.copyBufferToBuffer( outputBuffer, 0, readBuffer, 0, - outputSize * 4 + outputSize * (hasF16 ? 2 : 4) ); // Submit work to GPU @@ -625,7 +625,7 @@ export async function CUMSUM3D(inputArray : ArrayBufferView, dimInfo : {shape: // Map staging buffer to read results await readBuffer.mapAsync(GPUMapMode.READ); const resultArrayBuffer = readBuffer.getMappedRange(); - const results = new Float32Array(resultArrayBuffer.slice()); + const results = hasF16 ? new Float16Array(resultArrayBuffer.slice()) : new Float16Array(new Float32Array(resultArrayBuffer.slice())); // Clean up readBuffer.unmap(); diff --git a/src/components/plots/AxisLines.tsx b/src/components/plots/AxisLines.tsx index 86d9a67d6..6d64d8f0f 100644 --- a/src/components/plots/AxisLines.tsx +++ b/src/components/plots/AxisLines.tsx @@ -53,20 +53,28 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli hideAxisControls: state.hideAxisControls }))) + const { axisMapping } = useZarrStore(useShallow(state => ({ + axisMapping: state.axisMapping + }))) + const shapeLength = dimArrays.length + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + const zIdx = axisMapping.z >= 0 ? axisMapping.z : shapeLength - 3; + const dimSlices = useMemo(()=> { let slices = [ - dimArrays[shapeLength-3].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - revY ? dimArrays[shapeLength-2].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined).reverse() : dimArrays[shapeLength-2].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), - dimArrays[shapeLength-1].slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined), + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + revY ? dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined).reverse() ?? [] : dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? [], ] if (coarsen) { const {kernelDepth, kernelSize} = useZarrStore.getState() slices = slices.map((val, idx) => coarsenFlatArray(val, (idx === 0 ? kernelDepth : kernelSize))) } return slices - },[revY, dimArrays, zSlice, ySlice, xSlice, coarsen]) + },[revY, dimArrays, zSlice, ySlice, xSlice, coarsen, axisMapping, shapeLength]) const dimLengths = dimSlices.map(val => val.length) const [xResolution, setXResolution] = useState(AXIS_CONSTANTS.INITIAL_RESOLUTION) @@ -114,9 +122,9 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli const zDimScale = zResolution/(zResolution-1) const zValDelta = 1/(zResolution-1) - const xTitleOffset = useMemo(() => (dimNames[shapeLength - 1]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const yTitleOffset = useMemo(() => (dimNames[shapeLength - 2]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const zTitleOffset = useMemo(() => (dimNames[shapeLength - 3]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const xTitleOffset = useMemo(() => ((dimNames[xIdx]?.length || 0) * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale, xIdx]); + const yTitleOffset = useMemo(() => ((dimNames[yIdx]?.length || 0) * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale, yIdx]); + const zTitleOffset = useMemo(() => ((dimNames[zIdx]?.length || 0) * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale, zIdx]); return ( @@ -140,7 +148,7 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli material-depthTest={false} rotation={[-Math.PI/2, 0, flipX ? Math.PI : 0]} position={[0, 0, flipX ? -AXIS_CONSTANTS.TICK_LENGTH_FACTOR*globalScale : AXIS_CONSTANTS.TICK_LENGTH_FACTOR*globalScale]} - >{parseLoc(dimSlices[2][Math.floor((dimLengths[2]-1)*idx*xValDelta)],dimUnits[shapeLength - 1])} + >{parseLoc(dimSlices[2]?.[Math.floor((dimLengths[2]-1)*idx*xValDelta)] || 0,dimUnits[xIdx])} ))} @@ -151,7 +159,7 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli fontSize={AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR*globalScale} color={colorHex} material-depthTest={false} - >{dimNames[shapeLength - 1]} + >{dimNames[xIdx]} {xResolution < AXIS_CONSTANTS.MAX_RESOLUTION && {parseLoc(dimSlices[0][(Math.floor((dimLengths[0]-1)*idx*zValDelta)+Math.floor(dimLengths[0]*animProg))%dimLengths[0]],dimUnits[shapeLength - 3])} + >{parseLoc(dimSlices[0]?.[(Math.floor((dimLengths[0]-1)*idx*zValDelta)+Math.floor(dimLengths[0]*animProg))%dimLengths[0]] || 0,dimUnits[zIdx])} ))} @@ -215,7 +223,7 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli fontSize={AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR*globalScale} color={colorHex} material-depthTest={false} - >{dimNames[shapeLength - 3]} + >{dimNames[zIdx]} {zResolution < AXIS_CONSTANTS.MAX_RESOLUTION && @@ -270,7 +278,7 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli material-depthTest={false} rotation={[0, flipX ? Math.PI : 0, 0]} position={[flipY ? -0.07*globalScale : 0.07*globalScale, 0, 0]} - >{parseLoc(dimSlices[1][Math.floor((dimLengths[1]-1)*idx*yValDelta)],dimUnits[shapeLength - 2])} + >{parseLoc(dimSlices[1]?.[Math.floor((dimLengths[1]-1)*idx*yValDelta)] || 0,dimUnits[yIdx])} ))} @@ -283,7 +291,7 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli material-depthTest={false} rotation={[0, 0, Math.PI / 2]} > - {dimNames[shapeLength - 2]} + {dimNames[yIdx]} @@ -364,22 +372,30 @@ const FlatAxis = () =>{ analysisMode: state.analysisMode, axis: state.axis }))) + const { axisMapping } = useZarrStore(useShallow(state => ({ + axisMapping: state.axisMapping + }))) const shapeLength = dimArrays.length; const is4D = dimArrays.length === 4; const originallyFlat = dimArrays.length == 2; const slices = originallyFlat ? [ySlice, xSlice] : [zSlice, ySlice, xSlice] - - const dimSlices = useMemo(()=>originallyFlat ? + const xIdx = (axisMapping.x >= 0 && axisMapping.x < shapeLength) ? axisMapping.x : shapeLength - 1; + const yIdx = (axisMapping.y >= 0 && axisMapping.y < shapeLength) ? axisMapping.y : Math.max(0, shapeLength - 2); + const zIdx = (axisMapping.z >= 0 && axisMapping.z < shapeLength) ? axisMapping.z : Math.max(0, shapeLength - 3); + + const dimSlices = useMemo(()=> { + return originallyFlat ? [ - flipY ? dimArrays[0].slice().reverse() : dimArrays[0], // Need the slice because inside useMemo it doesn't mutate the original properly - dimArrays[1] + flipY ? dimArrays[yIdx]?.slice().reverse() ?? [] : dimArrays[yIdx] ?? [], + dimArrays[xIdx] ?? [] ] : [ - dimArrays[shapeLength-3].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - flipY ? dimArrays[shapeLength-2].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined).reverse() : dimArrays[shapeLength-2].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), - dimArrays[shapeLength-1].slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined), - ],[dimArrays, flipY]) + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + flipY ? dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined).reverse() ?? [] : dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? [], + ] + },[dimArrays, flipY, axisMapping, originallyFlat, shapeLength, xSlice, ySlice, zSlice]) const dimLengths = useMemo(()=>{ if (analysisMode && !originallyFlat){ @@ -399,34 +415,31 @@ const FlatAxis = () =>{ const { axisArrays, axisUnits, axisNames } = useMemo(() => { - if (analysisMode && !originallyFlat) { + if (originallyFlat) { return { - axisArrays: dimSlices.filter((_val, idx) => idx != axis), - axisUnits: is4D - ? dimUnits.slice(1).filter((_val, idx) => idx != axis) - : dimUnits.filter((_val, idx) => idx != axis), - axisNames: is4D - ? dimNames.slice(1).filter((_val, idx) => idx != axis) - : dimNames.filter((_val, idx) => idx != axis) + axisArrays: dimSlices, + axisUnits: [dimUnits[yIdx], dimUnits[xIdx]], + axisNames: [dimNames[yIdx], dimNames[xIdx]], }; - } else if (!originallyFlat) { + } + + const baseUnits = [dimUnits[zIdx], dimUnits[yIdx], dimUnits[xIdx]]; + const baseNames = [dimNames[zIdx], dimNames[yIdx], dimNames[xIdx]]; + + if (analysisMode) { return { - axisArrays: dimSlices, - axisUnits: is4D - ? dimUnits.slice(1) - : dimUnits, - axisNames: is4D - ? dimNames.slice(1) - : dimNames + axisArrays: dimSlices.filter((_val, idx) => idx != axis), + axisUnits: baseUnits.filter((_val, idx) => idx != axis), + axisNames: baseNames.filter((_val, idx) => idx != axis), }; } else { return { axisArrays: dimSlices, - axisUnits: dimUnits, - axisNames: dimNames, + axisUnits: baseUnits, + axisNames: baseNames, }; } - }, [analysisMode, dimArrays, dimUnits, is4D, dimNames, dimSlices]); + }, [analysisMode, dimArrays, dimUnits, dimNames, dimSlices, originallyFlat, axisMapping, shapeLength, axis]); const shapeRatio = useMemo(()=>{ @@ -462,8 +475,8 @@ const FlatAxis = () =>{ const yDimScale = yResolution/(yResolution-1) const yValDelta = 1/(yResolution-1) - const xTitleOffset = useMemo(() => (axisNames[widthIdx].length * FLAT_AXIS_CONSTANTS.TITLE_FONT_SIZE / 2 + 0.1), [axisNames, widthIdx]); - const yTitleOffset = useMemo(() => (axisNames[heightIdx].length * FLAT_AXIS_CONSTANTS.TITLE_FONT_SIZE / 2 + 0.1), [axisNames, heightIdx]); + const xTitleOffset = useMemo(() => ((axisNames[widthIdx]?.length || 0) * FLAT_AXIS_CONSTANTS.TITLE_FONT_SIZE / 2 + 0.1), [axisNames, widthIdx]); + const yTitleOffset = useMemo(() => ((axisNames[heightIdx]?.length || 0) * FLAT_AXIS_CONSTANTS.TITLE_FONT_SIZE / 2 + 0.1), [axisNames, heightIdx]); return ( { +export const DataCube = ({ volTexture: propVolTexture }: DataCubeProps ) => { + const volTexture = usePaddedTextures(propVolTexture); const {shape, colormap, flipY, textureArrayDepths} = useGlobalStore(useShallow(state=>({ shape:state.shape, colormap:state.colormap, @@ -47,7 +49,7 @@ export const DataCube = ({ volTexture }: DataCubeProps ) => { glslVersion: THREE.GLSL3, uniforms: { modelViewMatrixInverse: { value: new THREE.Matrix4() }, // Used for Orthographic RayMarcher - map: { value: Array.from({ length: 14 }, (_, idx) => volTexture?.[idx])}, + map: { value: volTexture}, maskTexture: { value: maskTexture }, maskValue: {value: maskValue }, textureDepths: {value: new THREE.Vector3(textureArrayDepths[2], textureArrayDepths[1], textureArrayDepths[0])}, @@ -69,6 +71,10 @@ export const DataCube = ({ volTexture }: DataCubeProps ) => { nanColor: {value: new THREE.Color(nanColor)}, fillValue: {value: fillValue?? NaN} }, + defines: { + USE_VORIGIN: 1, + USE_VDIRECTION: 1 + }, vertexShader: useOrtho ? orthoVertex : vertexShader, fragmentShader: useFragOpt ? fragOpt : fragmentShader, transparent: true, diff --git a/src/components/plots/FlatBlocks.tsx b/src/components/plots/FlatBlocks.tsx index 2bddce356..83696fd0b 100644 --- a/src/components/plots/FlatBlocks.tsx +++ b/src/components/plots/FlatBlocks.tsx @@ -10,8 +10,10 @@ import { invalidate } from '@react-three/fiber' import { deg2rad } from '@/utils/HelperFuncs' import { useCoordBounds } from '@/hooks/useCoordBounds' import { GetVert } from '../textures/GetVert'; +import { usePaddedTextures } from '@/hooks/usePaddedTextures'; -const FlatBlocks = ({textures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { +const FlatBlocks = ({textures: propTextures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { + const textures = usePaddedTextures(propTextures); const {colormap, isFlat, valueScales, flipY, dataShape, textureArrayDepths} = useGlobalStore(useShallow(state=>({ colormap: state.colormap, diff --git a/src/components/plots/FlatMap.tsx b/src/components/plots/FlatMap.tsx index 9d86eca75..0894d92e3 100644 --- a/src/components/plots/FlatMap.tsx +++ b/src/components/plots/FlatMap.tsx @@ -14,6 +14,7 @@ import { evaluate_cmap } from 'js-colormaps-es'; import { useCoordBounds } from '@/hooks/useCoordBounds'; import { GetFrag } from '../textures'; import { SquareMeshes } from './TransectMeshes'; +import { usePaddedTextures } from '@/hooks/usePaddedTextures'; interface InfoSettersProps{ setLoc: React.Dispatch>; setShowInfo: React.Dispatch>; @@ -21,7 +22,8 @@ interface InfoSettersProps{ coords: React.RefObject; } -const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THREE.Data3DTexture[], infoSetters : InfoSettersProps}) => { +const FlatMap = ({textures: propTextures, infoSetters} : {textures : THREE.DataTexture[] | THREE.Data3DTexture[], infoSetters : InfoSettersProps}) => { + const textures = usePaddedTextures(propTextures); const {setLoc, setShowInfo, val, coords} = infoSetters; const {flipY, colormap, dimArrays, dimNames, dimUnits, isFlat, dataShape, textureArrayDepths, strides, @@ -54,27 +56,31 @@ const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THR analysisMode: state.analysisMode, analysisArray: state.analysisArray }))) - const {kernelSize, kernelDepth} = useZarrStore(useShallow(state => ({ + const {kernelSize, kernelDepth, axisMapping} = useZarrStore(useShallow(state => ({ kernelSize: state.kernelSize, - kernelDepth: state.kernelDepth + kernelDepth: state.kernelDepth, + axisMapping: state.axisMapping }))) const shapeLength = dimArrays.length const dimSlices = useMemo (() => { - let slices = dimArrays.length === 2 + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + const zIdx = axisMapping.z >= 0 ? axisMapping.z : shapeLength - 3; + let slices = isFlat ? [ - dimArrays[0].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - dimArrays[1].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), + dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? [], ] : [ - dimArrays[shapeLength - 3].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - dimArrays[shapeLength - 2].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), - dimArrays[shapeLength - 1].slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined ) + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined ) ?? [], ] - if (coarsen) slices = slices.map((val, idx) => coarsenFlatArray(val, (idx === 0 ? kernelDepth : kernelSize))) + if (coarsen) slices = slices.map((val, idx) => coarsenFlatArray(val, (idx === 0 && slices.length > 2 ? kernelDepth : kernelSize))) return slices - } ,[dimArrays, zSlice, ySlice, xSlice, coarsen]) + } ,[dimArrays, zSlice, ySlice, xSlice, coarsen, axisMapping, shapeLength, kernelDepth, kernelSize]) const shapeRatio = useMemo(()=> { if (dataShape.length == 2){ @@ -91,7 +97,20 @@ const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THR const lastUV = useRef(new THREE.Vector2(0,0)) const rotateMap = analysisMode && axis == 2; const sampleArray = useMemo(()=> analysisMode ? analysisArray : GetCurrentArray(),[analysisMode, analysisArray, textures]) - const analysisDims = useMemo(()=>dimArrays.length > 2 ? dimSlices.filter((_e,idx)=> idx != axis) : dimSlices,[dimSlices,axis]) + const analysisDims = useMemo(() => { + if (!analysisMode) return dimSlices; + const zIdx = axisMapping.z >= 0 ? axisMapping.z : shapeLength - 3; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const fullSlices = [ + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? [], + ]; + let slices = fullSlices.filter((_, idx) => idx !== axis); + if (coarsen) slices = slices.map((val, idx) => coarsenFlatArray(val, (idx === 0 && slices.length > 2 ? kernelDepth : kernelSize))) + return slices; + }, [analysisMode, dimSlices, dimArrays, zSlice, ySlice, xSlice, axisMapping, shapeLength, axis, coarsen, kernelDepth, kernelSize]) const {lonBounds, latBounds} = useCoordBounds() @@ -107,8 +126,10 @@ const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THR setLoc([e.clientX, e.clientY]); lastUV.current = e.uv; const { x, y } = e.uv; - const xSize = isFlat ? (analysisMode ? analysisDims[1].length : dimSlices[1].length) : dimSlices[2].length; - const ySize = isFlat ? (analysisMode ? analysisDims[0].length : dimSlices[0].length) : dimSlices[1].length; + const zSliceIdx = dimSlices.length > 2 ? 2 : 1; + const ySliceIdx = dimSlices.length > 2 ? 1 : 0; + const xSize = isFlat ? (analysisMode ? analysisDims[1].length : dimSlices[1].length) : dimSlices[zSliceIdx].length; + const ySize = isFlat ? (analysisMode ? analysisDims[0].length : dimSlices[0].length) : dimSlices[ySliceIdx].length; const xIdx = Math.round(x*xSize-.5) const yIdx = Math.round(y*ySize-.5) @@ -116,7 +137,7 @@ const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THR dataIdx += isFlat ? 0 : Math.floor((dimSlices[0].length-1) * animProg) * xSize*ySize const dataVal = sampleArray ? sampleArray[dataIdx] : 0; val.current = dataVal; - coords.current = isFlat ? analysisMode ? [analysisDims[0][yIdx], analysisDims[1][xIdx]] : [dimSlices[0][yIdx], dimSlices[1][xIdx]] : [dimSlices[1][yIdx], dimSlices[2][xIdx]] + coords.current = isFlat ? analysisMode ? [analysisDims[0][yIdx], analysisDims[1][xIdx]] : [dimSlices[0][yIdx], dimSlices[1][xIdx]] : [dimSlices[ySliceIdx][yIdx], dimSlices[zSliceIdx][xIdx]] } } @@ -168,7 +189,7 @@ const FlatMap = ({textures, infoSetters} : {textures : THREE.DataTexture[] | THR uniforms:{ cScale: {value: cScale}, cOffset: {value: cOffset}, - map : {value: Array.from({ length: 14 }, (_, idx) => textures?.[idx])}, + map : {value: textures}, maskTexture: {value: maskTexture}, maskValue: {value: maskValue}, threshold: {value: new THREE.Vector2(valueRange[0],valueRange[1])}, diff --git a/src/components/plots/Plot.tsx b/src/components/plots/Plot.tsx index 344d8ef94..9abfbe56c 100644 --- a/src/components/plots/Plot.tsx +++ b/src/components/plots/Plot.tsx @@ -160,17 +160,19 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } const Plot = () => { - const {colormap, isFlat, DPR, valueScales, setIsFlat} = useGlobalStore(useShallow(state=>({ + const {colormap, isFlat, DPR, valueScales, setIsFlat, dataShape} = useGlobalStore(useShallow(state=>({ colormap: state.colormap, isFlat: state.isFlat, DPR: state.DPR, valueScales: state.valueScales, setIsFlat: state.setIsFlat, + dataShape: state.dataShape, }))) const {keyFrameEditor} = useImageExportStore(useShallow(state => ({ keyFrameEditor:state.keyFrameEditor}))) - const {plotType, displaceSurface} = usePlotStore(useShallow(state => ({ + const {plotType, displaceSurface, setPlotType} = usePlotStore(useShallow(state => ({ plotType: state.plotType, displaceSurface: state.displaceSurface, + setPlotType: state.setPlotType, }))) const {analysisMode, useEditor} = useAnalysisStore(useShallow(state => ({ analysisMode: state.analysisMode, @@ -185,6 +187,18 @@ const Plot = () => { //DATA LOADING const {textures, show, stableMetadata, setTextures} = useDataFetcher() + useEffect(()=>{ + if (analysisMode) return; + const isEffectivelyFlat = dataShape.length === 2 || (dataShape.length === 3 && dataShape.includes(1)); + if (isEffectivelyFlat && plotType != "flat" && plotType != "sphere"){ + setPlotType("flat") + setIsFlat(true) + } else if (!isEffectivelyFlat && plotType != "volume" && plotType != "isosurface") { + setPlotType("volume") + setIsFlat(false) + } + },[dataShape, analysisMode]) + useEffect(()=>{ // Reset after analysis mode if(!analysisMode && show){ const {dataShape} = useGlobalStore.getState(); diff --git a/src/components/plots/Sphere.tsx b/src/components/plots/Sphere.tsx index a9eb589e4..f8d15b01b 100644 --- a/src/components/plots/Sphere.tsx +++ b/src/components/plots/Sphere.tsx @@ -9,6 +9,8 @@ import { evaluate_cmap } from 'js-colormaps-es'; import { useCoordBounds } from '@/hooks/useCoordBounds' import { GetFrag, GetVert } from '../textures'; import { SquareMeshes } from './TransectMeshes'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; +import { usePaddedTextures } from '@/hooks/usePaddedTextures'; function XYZtoRemap(xyz : THREE.Vector3, latBounds: number[], lonBounds : number[]){ const lon = Math.atan2(xyz.z,xyz.x) const lat = Math.asin(xyz.y); @@ -17,7 +19,8 @@ function XYZtoRemap(xyz : THREE.Vector3, latBounds: number[], lonBounds : number return new THREE.Vector2(1-u,v) } -export const Sphere = ({textures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { +export const Sphere = ({textures: propTextures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { + const textures = usePaddedTextures(propTextures); const {setPlotDim,updateDimCoords, updateTimeSeries} = useGlobalStore(useShallow(state=>({ setPlotDim:state.setPlotDim, updateDimCoords:state.updateDimCoords, @@ -62,10 +65,17 @@ export const Sphere = ({textures} : {textures: THREE.Data3DTexture[] | THREE.Dat getColorIdx: state.getColorIdx, incrementColorIdx: state.incrementColorIdx }))) + const { axisMapping } = useZarrStore(useShallow(state => ({ + axisMapping: state.axisMapping + }))) + const shapeLength = dimArrays.length; + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + const zIdx = axisMapping.z >= 0 ? axisMapping.z : shapeLength - 3; const dimSlices = [ - dimArrays[0].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - dimArrays[1].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), - dimArrays.length > 2 ? dimArrays[2].slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) : [], + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays.length > 2 ? (dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? []) : [], ] const {lonBounds, latBounds} = useCoordBounds() @@ -75,7 +85,7 @@ export const Sphere = ({textures} : {textures: THREE.Data3DTexture[] | THREE.Dat const shader = new THREE.ShaderMaterial({ glslVersion: THREE.GLSL3, uniforms: { - map: { value: Array.from({ length: 14 }, (_, idx) => textures?.[idx]) }, + map: { value: textures }, maskTexture: { value: maskTexture}, maskValue: { value: maskValue }, threshold: {value: new THREE.Vector2(valueRange[0],valueRange[1])}, diff --git a/src/components/plots/SphereBlocks.tsx b/src/components/plots/SphereBlocks.tsx index d32641ffb..ef9dad6ca 100644 --- a/src/components/plots/SphereBlocks.tsx +++ b/src/components/plots/SphereBlocks.tsx @@ -9,7 +9,9 @@ import { invalidate } from '@react-three/fiber' import { deg2rad } from '@/utils/HelperFuncs' import { useCoordBounds } from '@/hooks/useCoordBounds' import { GetVert } from '../textures'; -const SphereBlocks = ({textures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { +import { usePaddedTextures } from '@/hooks/usePaddedTextures'; +const SphereBlocks = ({textures: propTextures} : {textures: THREE.Data3DTexture[] | THREE.DataTexture[] | null}) => { + const textures = usePaddedTextures(propTextures); const {colormap, isFlat, valueScales, dataShape, textureArrayDepths} = useGlobalStore(useShallow(state=>({ colormap: state.colormap, diff --git a/src/components/plots/plotarea/FixedTicks.tsx b/src/components/plots/plotarea/FixedTicks.tsx index 04eecc8cd..924a24375 100644 --- a/src/components/plots/plotarea/FixedTicks.tsx +++ b/src/components/plots/plotarea/FixedTicks.tsx @@ -5,6 +5,7 @@ import { parseTimeUnit } from '@/utils/HelperFuncs' import { Fragment } from 'react' import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { usePlotStore } from '@/GlobalStates/PlotStore'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; import { useShallow } from 'zustand/shallow' @@ -51,11 +52,20 @@ export function FixedTicks({ ySlice: state.ySlice, xSlice: state.xSlice }))) - const dimSlices = useMemo(() => [ - dimArrays[0].slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined), - dimArrays[1].slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined), - dimArrays[2].slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined), - ], [dimArrays, xSlice, ySlice, zSlice]) + const { axisMapping } = useZarrStore(useShallow(state => ({ + axisMapping: state.axisMapping + }))) + const shapeLength = dimArrays.length; + const dimSlices = useMemo(() => { + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + const zIdx = axisMapping.z >= 0 ? axisMapping.z : shapeLength - 3; + return [ + dimArrays[zIdx]?.slice(zSlice[0], zSlice[1] ? zSlice[1] : undefined) ?? [], + dimArrays[yIdx]?.slice(ySlice[0], ySlice[1] ? ySlice[1] : undefined) ?? [], + dimArrays[xIdx]?.slice(xSlice[0], xSlice[1] ? xSlice[1] : undefined) ?? [], + ] + }, [dimArrays, xSlice, ySlice, zSlice, axisMapping, shapeLength]) const xDimArray = useMemo(() => dimSlices[plotDim], [dimSlices, plotDim]) const xTickCount = 10; const yTickCount = 8; diff --git a/src/components/plots/plotarea/ThickLine.tsx b/src/components/plots/plotarea/ThickLine.tsx index 57fafc231..92350f72f 100644 --- a/src/components/plots/plotarea/ThickLine.tsx +++ b/src/components/plots/plotarea/ThickLine.tsx @@ -71,7 +71,7 @@ const ThickLine = ({height, xScale, yScale, pointSetters} : ThickLineProps) => { uniform bool useMapColors; uniform vec3 lineColor; uniform vec3 userColor; - varying float vNormed; + in float vNormed; void main() { vec4 texColor = texture(cmap, vec2(vNormed, 0.1)); diff --git a/src/components/textures/shaders/LandingVertex.glsl b/src/components/textures/shaders/LandingVertex.glsl index fcc021b18..95a703b87 100644 --- a/src/components/textures/shaders/LandingVertex.glsl +++ b/src/components/textures/shaders/LandingVertex.glsl @@ -9,7 +9,7 @@ attribute vec3 aSpherePosition; attribute vec3 aCubePosition; attribute vec3 aPlanePosition; -varying vec3 vColor; +out vec3 vColor; void main() { // Linearly interpolate between the three shapes using the mix uniforms diff --git a/src/components/textures/shaders/LineVert.glsl b/src/components/textures/shaders/LineVert.glsl index e5df81167..c5e0a76d4 100644 --- a/src/components/textures/shaders/LineVert.glsl +++ b/src/components/textures/shaders/LineVert.glsl @@ -3,7 +3,7 @@ attribute vec3 next; attribute vec3 previous; attribute float normed; -varying float vNormed; +out float vNormed; uniform float aspect; uniform float thickness; diff --git a/src/components/textures/shaders/flatFrag.glsl b/src/components/textures/shaders/flatFrag.glsl index bab398755..ba49450b9 100644 --- a/src/components/textures/shaders/flatFrag.glsl +++ b/src/components/textures/shaders/flatFrag.glsl @@ -21,7 +21,7 @@ uniform vec2 threshold; uniform int maskValue; uniform float fillValue; -varying vec2 vUv; +in vec2 vUv; out vec4 Color; #define epsilon 0.0001 #define PI 3.14159265 diff --git a/src/components/textures/shaders/sphereBlocksFrag.glsl b/src/components/textures/shaders/sphereBlocksFrag.glsl index 48ed12144..a0c9f8ace 100644 --- a/src/components/textures/shaders/sphereBlocksFrag.glsl +++ b/src/components/textures/shaders/sphereBlocksFrag.glsl @@ -2,7 +2,7 @@ uniform sampler2D cmap; uniform float cOffset; uniform float cScale; -varying float vStrength; +in float vStrength; out vec4 Color; diff --git a/src/components/textures/shaders/sphereVertex.glsl b/src/components/textures/shaders/sphereVertex.glsl index dffa7f30e..94a727592 100644 --- a/src/components/textures/shaders/sphereVertex.glsl +++ b/src/components/textures/shaders/sphereVertex.glsl @@ -52,10 +52,10 @@ float sample1( out vec3 aPosition; void main() { - vec2 uv = giveUV(position); // We can't just pass this as a varying because the fragment will try to interpoalte between the seems which looks bad + vec2 texCoord = giveUV(position); // We can't just pass this as a varying because the fragment will try to interpoalte between the seems which looks bad int yStepSize = int(textureDepths.x); - bool inBounds = all(greaterThanEqual(uv, vec2(0.0))) && - all(lessThanEqual(uv, vec2(1.0))); + bool inBounds = all(greaterThanEqual(texCoord, vec2(0.0))) && + all(lessThanEqual(texCoord, vec2(1.0))); aPosition = position; if (inBounds){ vec3 normal = normalize(position); diff --git a/src/components/textures/shaders/thickLineVert.glsl b/src/components/textures/shaders/thickLineVert.glsl index c27a6e100..6314e147b 100644 --- a/src/components/textures/shaders/thickLineVert.glsl +++ b/src/components/textures/shaders/thickLineVert.glsl @@ -3,7 +3,7 @@ attribute vec3 next; attribute vec3 previous; attribute float normed; -varying float vNormed; +out float vNormed; uniform float zoom; uniform float thickness; diff --git a/src/components/textures/shaders/vertex.glsl b/src/components/textures/shaders/vertex.glsl index 5c0b968e1..2c9aa0a14 100644 --- a/src/components/textures/shaders/vertex.glsl +++ b/src/components/textures/shaders/vertex.glsl @@ -1,17 +1,41 @@ // by Jeran Poehls +#ifdef USE_VORIGIN out vec3 vOrigin; +#endif + +#ifdef USE_VDIRECTION out vec3 vDirection; +#endif + +#ifdef USE_APOSITION out vec3 aPosition; +#endif +#ifdef USE_VUV out vec2 Vuv; +#endif void main() { vec4 worldPos = modelViewMatrix * vec4( position, 1.0 ); +#ifdef USE_APOSITION aPosition = position; //Pass out position for sphere frag +#endif + +#ifdef USE_VORIGIN vOrigin = vec3( inverse( modelMatrix ) * vec4( cameraPosition, 1.0 ) ).xyz; +#endif + +#ifdef USE_VDIRECTION +#ifdef USE_VORIGIN vDirection = position - vOrigin; +#endif +#endif + +#ifdef USE_VUV Vuv = uv; +#endif + gl_Position = projectionMatrix * worldPos; } \ No newline at end of file diff --git a/src/components/ui/Colorbar.tsx b/src/components/ui/Colorbar.tsx index 5ef738646..561423e30 100644 --- a/src/components/ui/Colorbar.tsx +++ b/src/components/ui/Colorbar.tsx @@ -251,7 +251,7 @@ const Colorbar = ({units, metadata, valueScales} : {units: string, metadata: Rec left:'50%', transform:'translateX(-50%)', }}> - {} + {} {`${analysisString}`}

{(cScale != 1 || cOffset != 0) && = { + scalar: 'border-l-teal-700', + slice: 'border-l-[#644FF0]', +}; + +export interface DimOption { + name: string; + label?: string; + size: number; + values?: number[]; + formatValue?: (value: number) => string; +} + +export interface DimSlicerProps { + availableDims: DimOption[]; + dimName: string; + onDimChange: (dimName: string) => void; + onRemove?: () => void; + dimSize: number; + selection: SliceSelectionState; + onChange: (next: SliceSelectionState) => void; + step?: number; + axis?: Axis; + onAxisChange?: (axis: Axis) => void; + values?: number[]; + formatValue?: (value: number) => string; + /** If set, locks the mode and hides the mode toggle */ + lockMode?: SelectionMode; + /** If set, restricts which axes are shown in the axis toggle */ + allowedAxes?: Axis[]; +} + +const DimSlicer: React.FC = ({ + availableDims, + dimName, + onDimChange, + onRemove, + dimSize, + selection, + onChange, + step = 1, + axis: propAxis = 'x', + onAxisChange, + values, + formatValue, + lockMode, + allowedAxes, +}) => { + // const [currentAxis, setCurrentAxis] = useState(propAxis); + const effectiveDimSize = values ? values.length : dimSize; + const rawSel = selection ?? defaultSelection(effectiveDimSize); + const sel = lockMode ? { ...rawSel, mode: lockMode } : rawSel; + + const getIndexFromValue = (val: number): number => { + if (!values || values.length === 0) { + return clamp(Math.round(val / step) * step, 0, maxIndex); + } + let closestIndex = 0; + let minDiff = Math.abs(Number(values[0]) - val); + for (let i = 1; i < values.length; i++) { + const diff = Math.abs(Number(values[i]) - val); + if (diff < minDiff) { + minDiff = diff; + closestIndex = i; + } + } + return closestIndex; + }; + + const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)); + + const parseOr = (v: string, fallback: number) => { + const n = parseInt(v, 10); + return Number.isNaN(n) ? fallback : n; + }; + + const maxIndex = Math.max(effectiveDimSize - 1, 0); + + const changeScalarBy = (delta: number) => { + let val = parseOr(sel.scalar, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, scalar: String(val) }); + }; + + const changeStartBy = (delta: number) => { + let val = parseOr(sel.start, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, start: String(val) }); + }; + + const changeStopBy = (delta: number) => { + let val = parseOr(sel.stop, maxIndex) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, stop: String(val) }); + }; + + const updateSelection = (patch: Partial) => { + const next = { ...sel, ...patch }; + if (lockMode) next.mode = lockMode; + onChange(next); + }; + + const startIndex = clamp(parseOr(sel.start, 0), 0, maxIndex); + const stopIndex = clamp(parseOr(sel.stop, maxIndex), 0, maxIndex); + const scalarIndex = clamp(parseOr(sel.scalar, 0), 0, maxIndex); + + const startValue = values && effectiveDimSize > 0 && startIndex < values.length ? String(values[startIndex]) : sel.start; + const stopValue = values && effectiveDimSize > 0 && stopIndex < values.length ? String(values[stopIndex]) : sel.stop; + const scalarValue = values && effectiveDimSize > 0 && scalarIndex < values.length ? String(values[scalarIndex]) : sel.scalar; + + const formattedValue = useCallback( + (index: number) => + values && effectiveDimSize > 0 && index < values.length + ? String(formatValue ? formatValue(values[index]) : values[index].toString()) + : String(index), + [values, effectiveDimSize, formatValue] + ); + + const isTimeDimension = dimName.toLowerCase().includes('time'); + const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date'); + const showTimeControls = Boolean(values && isTimeDimension); + + return ( +
+ + {onRemove && ( + + )} + + {/* Top row: dim select + mode toggle + axis toggle */} +
+ + +
+ {!lockMode && ( + updateSelection({ mode })} + /> + )} + {sel.mode === 'slice' && ( + + {propAxis} + + )} +
+
+ + {/* Slider */} + {sel.mode === 'slice' && ( +
+ + updateSelection({ start: String(newStart), stop: String(newStop) }) + } + className="w-full cursor-pointer [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {sel.mode === 'scalar' && ( +
+ updateSelection({ scalar: String(val) })} + className="w-full cursor-pointer [&_[data-slot=slider-range]]:bg-transparent [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {/* Bottom controls */} + {isDateDimension ? ( + sel.mode === 'scalar' ? ( +
+ updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> +
+ ) : ( +
+ updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> +
+ updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> +
+
+ ) + ) : ( +
+ {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + ariaLabel="Start value" + showInput={!isDateDimension} + /> + ) + ) : ( +
+ )} + + {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + ariaLabel="Stop value" + showInput={!isDateDimension} + /> + ) + ) : showTimeControls ? ( + updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + ariaLabel="Scalar value" + showInput={!isDateDimension} + /> + )} +
+ )} +
+ ); +}; + +export { DimSlicer }; +export default DimSlicer; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx new file mode 100644 index 000000000..e4f1baf09 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -0,0 +1,73 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button-enhanced'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type Axis = 'x' | 'y' | 'z' | 'c'; + +const AXIS_CLASS: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +interface DimSlicerAxisToggleProps { + axis: Axis; + onAxisChange?: (axis: Axis) => void; + /** If provided, only these axes are shown. Defaults to all four. */ + allowedAxes?: Axis[]; +} + +export const DimSlicerAxisToggle: React.FC = ({ + axis, + onAxisChange, + allowedAxes, +}) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + const axisOptions: Axis[] = allowedAxes ?? ['x', 'y', 'z', 'c']; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + {axisOptions.map(a => ( + + ))} + + ) : ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx new file mode 100644 index 000000000..14af38ec2 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -0,0 +1,67 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button-enhanced'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type SelectionMode = 'scalar' | 'slice'; + +interface DimSlicerModeToggleProps { + mode: SelectionMode; + onModeChange: (nextMode: SelectionMode) => void; +} + +export const DimSlicerModeToggle: React.FC = ({ mode, onModeChange }) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + + + + ) : ( + + )} +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx new file mode 100644 index 000000000..2a9494b65 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx @@ -0,0 +1,36 @@ +'use client' + +import React from 'react' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerNumericControlProps { + value: string + placeholder: string + ariaLabel: string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + showInput: boolean +} + +export function DimSlicerNumericControl({ + value, + placeholder, + ariaLabel, + onValueChange, + onIncrement, + onDecrement, + showInput, +}: DimSlicerNumericControlProps) { + return ( + + ) +} diff --git a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx new file mode 100644 index 000000000..3b9aa9a08 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -0,0 +1,97 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button-enhanced'; +import { ButtonGroup } from '@/components/ui/button-group'; +import { MinusIcon, PlusIcon } from 'lucide-react'; + +interface DimSlicerNumericInputWithStepperProps { + value: string; + placeholder: string; + onValueChange: (value: string) => void; + onIncrement: () => void; + onDecrement: () => void; + ariaLabel: string; + showInput?: boolean; +} + +export const DimSlicerNumericInputWithStepper: React.FC = ({ + value, + placeholder, + onValueChange, + onIncrement, + onDecrement, + ariaLabel, + showInput = true, +}) => { + const [expanded, setExpanded] = useState(false); + const [localValue, setLocalValue] = useState(value); + const rootRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Sync prop changes to local state + useEffect(() => { + setLocalValue(value); + }, [value]); + + const commitValue = () => { + onValueChange(localValue); + setLocalValue(value); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + commitValue(); + } + }; + + return ( +
+ + {showInput && ( + setLocalValue(e.target.value)} + onBlur={commitValue} + onKeyDown={handleKeyDown} + onClick={() => setExpanded(false)} + className="no-spinner h-7 text-xs w-16 text-center appearance-none" + placeholder={placeholder} + aria-label={ariaLabel} + /> + )} + {expanded ? ( + + + + + ) : ( + + )} + +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx new file mode 100644 index 000000000..c74e4e704 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import TimeCombobox from './TimeCombobox' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerTimeControlProps { + currentIndex: number + onIndexChange: (index: number) => void + value: string + placeholder: string + ariaLabel: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + includeEnd?: boolean + layout?: 'row' | 'column' + showInput?: boolean +} + +export function DimSlicerTimeControl({ + currentIndex, + onIndexChange, + value, + placeholder, + ariaLabel, + values, + effectiveDimSize, + formattedValue, + onValueChange, + onIncrement, + onDecrement, + includeEnd = false, + layout = 'column', + showInput = true, +}: DimSlicerTimeControlProps) { + return ( +
+
+ +
+ +
+ ) +} diff --git a/src/components/ui/DimSlicer/TimeCombobox.tsx b/src/components/ui/DimSlicer/TimeCombobox.tsx new file mode 100644 index 000000000..71acfd650 --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,195 @@ +'use client' +import React, { useState, useMemo, useEffect } from 'react' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox' + +interface TimeComboboxProps { + currentIndex: number + onIndexChange: (index: number) => void + ariaLabel: string + placeholder: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + includeEnd?: boolean +} + +export default function TimeCombobox({ + currentIndex, + onIndexChange, + ariaLabel, + placeholder, + values, + effectiveDimSize, + formattedValue, + includeEnd = false, +}: TimeComboboxProps) { + const selectedLabel = + includeEnd && currentIndex === effectiveDimSize + ? formattedValue(Math.max(effectiveDimSize - 1, 0)) + : formattedValue(currentIndex) + + const [inputQuery, setInputQuery] = useState('') + const inputValue = inputQuery === '' ? selectedLabel : inputQuery + + const [windowRadius, setWindowRadius] = useState(50) + + useEffect(() => { + setWindowRadius(50) + }, [inputValue, currentIndex]) + + const handleValueChange = (value: unknown) => { + const label = typeof value === 'string' ? value : '' + if (label === '') { + setInputQuery('') + onIndexChange(includeEnd ? effectiveDimSize : 0) + return + } + const item = labeledValues.find(({ label: l }) => l === label) + if (item) { + setInputQuery('') + onIndexChange(item.index) + } + } + + const labeledValues = useMemo(() => { + return values.map((_, i) => ({ label: formattedValue(i), index: i })) + }, [values, formattedValue]) + + const filteredData = useMemo(() => { + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + const isFiltering = normalizedInput !== '' && normalizedInput !== selectedQuery; + + if (isFiltering) { + const results = labeledValues.filter(({ label }) => label.toLowerCase().includes(normalizedInput)) + return { items: results.slice(0, windowRadius * 2), startIdx: 0 }; + } + + const startIdx = Math.max(0, currentIndex - windowRadius); + const endIdx = Math.min(labeledValues.length, currentIndex + windowRadius); + return { items: labeledValues.slice(startIdx, endIdx), startIdx }; + }, [inputValue, selectedLabel, labeledValues, windowRadius, currentIndex]) + + const filtered = filteredData.items; + + const listRef = React.useRef(null); + const lastVisibleItem = React.useRef<{ index: string; offset: number } | null>(null); + + React.useLayoutEffect(() => { + if (listRef.current && lastVisibleItem.current) { + const { index, offset } = lastVisibleItem.current; + const target = listRef.current; + const item = target.querySelector(`[data-index="${index}"]`); + if (item) { + const targetRect = target.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + const currentOffset = itemRect.top - targetRect.top; + const diff = currentOffset - offset; + + if (Math.abs(diff) > 0) { + target.scrollTop += diff; + } + } + // Also apply once in a microtask just in case Base UI auto-scrolls asynchronously + requestAnimationFrame(() => { + if (target && item) { + const newCurrentOffset = item.getBoundingClientRect().top - target.getBoundingClientRect().top; + const newDiff = newCurrentOffset - offset; + if (Math.abs(newDiff) > 0) { + target.scrollTop += newDiff; + } + } + }); + lastVisibleItem.current = null; + } + }, [filteredData]); + + const targetWidth = Math.min( + Math.max(Math.max(selectedLabel.length, placeholder.length) + 2, 12), + 40 + ) + + return ( + + setInputQuery(typeof value === 'string' ? value : String(value)) + } + autoHighlight + > + + + {filtered.length === 0 ? No items found. : null} + ) => { + const target = e.currentTarget; + const scrollPos = target.scrollTop; + const maxScroll = target.scrollHeight - target.clientHeight; + + const isNearBottom = maxScroll > 0 && maxScroll - scrollPos <= target.clientHeight; + const isNearTop = maxScroll > 0 && scrollPos <= target.clientHeight; + + if (isNearBottom || isNearTop) { + setWindowRadius(r => { + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + const isFiltering = normalizedInput !== '' && normalizedInput !== selectedQuery; + + let nextR = r; + if (isFiltering) { + nextR = isNearBottom ? Math.min(r + 50, 5000) : r; + } else { + const startIdx = Math.max(0, currentIndex - r); + const endIdx = Math.min(labeledValues.length, currentIndex + r); + const canGrowTop = startIdx > 0 && isNearTop; + const canGrowBottom = endIdx < labeledValues.length && isNearBottom; + if (canGrowTop || canGrowBottom) { + nextR = Math.min(r + 50, 5000); + } + } + + if (nextR !== r) { + const targetRect = target.getBoundingClientRect(); + const children = Array.from(target.querySelectorAll('[data-slot="combobox-item"]')); + for (const child of children) { + const childRect = child.getBoundingClientRect(); + if (childRect.bottom >= targetRect.top) { + lastVisibleItem.current = { + index: child.getAttribute('data-index') || '', + offset: childRect.top - targetRect.top + }; + break; + } + } + } + + return nextR; + }); + } + }} + > + {filtered.map(({ label, index }) => ( + + {label} + + ))} + + + + ) +} \ No newline at end of file diff --git a/src/components/ui/DimSlicer/index.ts b/src/components/ui/DimSlicer/index.ts new file mode 100644 index 000000000..0622ada38 --- /dev/null +++ b/src/components/ui/DimSlicer/index.ts @@ -0,0 +1,2 @@ +export { default } from './DimSlicer'; +export * from './DimSlicer'; diff --git a/src/components/ui/MainPanel/AnalysisOptions.tsx b/src/components/ui/MainPanel/AnalysisOptions.tsx index b5ec11b9a..894e2ffa6 100644 --- a/src/components/ui/MainPanel/AnalysisOptions.tsx +++ b/src/components/ui/MainPanel/AnalysisOptions.tsx @@ -81,11 +81,12 @@ const webGPUError =
- {dimNames.map((dimName, idx) => ( - - {dimName} + {activeIndices.map((origIdx, dataShapeIdx) => ( + + {dimNames[origIdx]} ))} @@ -383,7 +384,7 @@ const AnalysisOptions = () => {
- + {setReverseDirection(e ? 1 : 0)}}/>
diff --git a/src/components/ui/MainPanel/Colormaps.tsx b/src/components/ui/MainPanel/Colormaps.tsx index 1e02d72a5..8713d3511 100644 --- a/src/components/ui/MainPanel/Colormaps.tsx +++ b/src/components/ui/MainPanel/Colormaps.tsx @@ -17,22 +17,24 @@ import { const Colormaps = () => { - const [cmap, setCmap] = useState("Spectral"); - const [flipCmap, setFlipCmap] = useState(false); const [hoveredCmap, setHoveredCmap] = useState(null); - const { colormap, setColormap } = useGlobalStore( + const { colormap, setColormap, colormapName, flipColormap, setColormapName, setFlipColormap } = useGlobalStore( useShallow((state) => ({ setColormap: state.setColormap, colormap: state.colormap, + colormapName: state.colormapName, + flipColormap: state.flipColormap, + setColormapName: state.setColormapName, + setFlipColormap: state.setFlipColormap, })) ); const [popoverSide, setPopoverSide] = useState<"left" | "top">("left"); useEffect(() => { setColormap( - GetColorMapTexture(colormap, (hoveredCmap || cmap) === "Default" ? "Spectral" : (hoveredCmap || cmap), 1, "#000000", 0, flipCmap) + GetColorMapTexture(colormap, (hoveredCmap || colormapName) === "Default" ? "Spectral" : (hoveredCmap || colormapName), 1, "#000000", 0, flipColormap) ); - }, [cmap, flipCmap, hoveredCmap]); + }, [colormapName, flipColormap, hoveredCmap]); useEffect(() => { const handleResize = () => { @@ -55,9 +57,9 @@ const Colormaps = () => { size="icon" className='cursor-pointer hover:scale-90 transition-transform duration-100 ease-out rounded-full' style={{ - backgroundImage: `url(./colormap_icons/${cmap}.webp)` , + backgroundImage: `url(./colormap_icons/${colormapName}.webp)` , backgroundSize: "100%", - transform: flipCmap ? "scaleX(-1)" : "", + transform: flipColormap ? "scaleX(-1)" : "", width: "32px", height: "32px", }} /> @@ -82,7 +84,7 @@ const Colormaps = () => { {colormaps.map((val) => ( {val} { onMouseEnter={() => setHoveredCmap(val)} onMouseLeave={() => setHoveredCmap(null)} onClick={() => { - setCmap(val); + setColormapName(val); setHoveredCmap(null); }} /> @@ -105,10 +107,10 @@ const Colormaps = () => { height: "50px", width: "50px", cursor: "pointer", - transform: `${flipCmap ? "rotate(270deg)" : "rotate(90deg)"}`, + transform: `${flipColormap ? "rotate(270deg)" : "rotate(90deg)"}`, transition: ".25s", }} - onClick={() => setFlipCmap((x) => !x)} + onClick={() => setFlipColormap(!flipColormap)} /> diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx new file mode 100644 index 000000000..e1906a418 --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,855 @@ +"use client"; + +import React, { useMemo, useState, useEffect } from 'react'; +import DimSlicer, { Axis, defaultSelection, DimOption, SliceSelectionState } from '@/components/ui/DimSlicer'; +import { defaultAttributes, renderAttributes } from "@/components/ui/MetaData"; +import { Button } from '@/components/ui/button-enhanced'; +import { useGlobalStore } from '@/GlobalStates/GlobalStore'; +import { useShallow } from 'zustand/shallow'; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Badge, Switch, Input, Hider } from "@/components/ui"; +import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert"; +import { parseLoc } from '@/utils/HelperFuncs'; +import { ChevronDown, ChevronRight, AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useIsMobile } from "@/hooks/use-mobile"; + +import { useCacheStore } from "@/GlobalStates/CacheStore"; +import { usePlotStore } from '@/GlobalStates/PlotStore'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; +import { SliderThumbs } from "@/components/ui/Widgets/SliderThumbs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { BsFillQuestionCircleFill } from "react-icons/bs"; + +const MAX_ACTIVE_DIMS = 3; + +const formatArray = (value: string | number[]): string => { + if (typeof value === 'string') return value; + return Array.isArray(value) ? value.join(', ') : String(value); +}; + +const formatBytes = (bytes: number): string => { + if (bytes === 0) return "0 Bytes" + const k = 1024 + const sizes = ["Bytes", "KB", "MB", "GB", "TB"] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i] +} + +interface DimInfo { + dimArrays: ArrayLike[]; + dimNames: string[]; + dimUnits: (string | null)[]; +} + +type Props = { + meta: { + name?: string; + shape?: number[]; + chunks?: number[]; + chunkSize?: number; + totalSize?: number; + dtype?: string; + long_name?: string; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[], dimNames: string[]) => void; + setShowMeta?: React.Dispatch>; + setOpenVariables?: (open: boolean) => void; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +interface SlicerRow { + id: number; + dimName: string; + sel: SliceSelectionState; + axis: Axis; +} + +let _nextId = 0; +const nextId = () => ++_nextId; + +const getOrigIdx = (dimName: string) => { + const parts = dimName.split('::'); + return parseInt(parts[parts.length - 1]); +}; + +export default function MetaDimSelector({ meta, metadata, onApply, setShowMeta, setOpenVariables }: Props) { + const isMobile = useIsMobile(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const dimArrays = useMemo( + () => (meta?.dimInfo?.dimArrays ?? []).map((a) => Array.from(a)), + [meta?.dimInfo?.dimArrays] + ); + const dimUnits = useMemo( + () => (meta?.dimInfo?.dimUnits ?? []).map((u) => u ?? ''), + [meta?.dimInfo?.dimUnits] + ); + const dimNames = useMemo( + () => meta?.dimInfo?.dimNames ?? [], + [meta?.dimInfo?.dimNames] + ); + const dataShape = meta?.shape || []; + const chunkShape = meta?.chunks || []; + + + const { setDimArrays, setDimNames, setDimUnits, initStore, setVariable, setTextureArrayDepths, variable } = useGlobalStore( + useShallow((state) => ({ + setDimArrays: state.setDimArrays, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, + initStore: state.initStore, + setVariable: state.setVariable, + setTextureArrayDepths: state.setTextureArrayDepths, + variable: state.variable, + })), + ); + + const { maxSize, cache, setMaxSize } = useCacheStore(useShallow(state => ({ maxSize: state.maxSize, cache: state.cache, setMaxSize: state.setMaxSize }))) + const [cacheSize, setCacheSize] = useState(maxSize) + + const { ndSlices, axisMapping, setZSlice, setYSlice, setXSlice, ReFetch, compress, setCompress, coarsen, setCoarsen, kernelSize, setKernelSize, kernelDepth, setKernelDepth } = useZarrStore(useShallow(state => ({ + ndSlices: state.ndSlices, axisMapping: state.axisMapping, + setZSlice: state.setZSlice, setYSlice: state.setYSlice, setXSlice: state.setXSlice, + ReFetch: state.ReFetch, compress: state.compress, setCompress: state.setCompress, + coarsen: state.coarsen, setCoarsen: state.setCoarsen, kernelSize: state.kernelSize, setKernelSize: state.setKernelSize, kernelDepth: state.kernelDepth, setKernelDepth: state.setKernelDepth + }))) + + const { maxTextureSize, max3DTextureSize } = usePlotStore(useShallow(state => ({ maxTextureSize: state.maxTextureSize, max3DTextureSize: state.max3DTextureSize }))) + + const [tooBig, setTooBig] = useState(false) + const [cached, setCached] = useState(false) + const [cachedChunks, setCachedChunks] = useState(null) + const [texCount, setTexCount] = useState(0) + const [displaySpat, setDisplaySpat] = useState(String(kernelSize)) + const [displayDepth, setDisplayDepth] = useState(String(kernelDepth)) + + useEffect(() => { + setDimArrays(dimArrays); + setDimNames(dimNames); + setDimUnits(dimUnits); + }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); + + + + const availableDims: DimOption[] = useMemo( + () => + dimArrays.map((values, idx) => { + const baseName = dimNames[idx] ?? `dim${idx}`; + // Always include idx to guarantee uniqueness during the "Default" fallback phase + const name = `${baseName}::${idx}`; + const label = baseName; + return { + name, + label, + size: values.length, + values, + formatValue: (v: number): string => + String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), + }; + }), + [dimArrays, dimNames, dimUnits], + ); + + const dimsKey = availableDims.map((d) => `${d.name}:${d.size}`).join('|'); + + const makeInitialCollapsedSels = (dims: DimOption[]): Record => { + const isCurrentVar = variable === meta.name && ndSlices && ndSlices.length === dims.length; + return Object.fromEntries(dims.map((d, i) => { + let sel: SliceSelectionState = { ...defaultSelection(d.size), mode: 'scalar' }; + if (isCurrentVar) { + const s = ndSlices[i]; + if (typeof s === 'number') { + sel = { start: '', stop: '', scalar: String(s), mode: 'scalar' }; + } + } + return [d.name, sel]; + })); + }; + + const makeInitialRows = (dims: DimOption[]): SlicerRow[] => { + const isCurrentVar = variable === meta.name && ndSlices && ndSlices.length === dims.length && axisMapping; + + if (isCurrentVar) { + const initRows: SlicerRow[] = []; + const axes: Axis[] = ['z', 'y', 'x']; + + for (const axis of axes) { + const mappedIdx = (axisMapping as Record)[axis]; + if (mappedIdx !== undefined && mappedIdx >= 0 && mappedIdx < dims.length) { + const dim = dims[mappedIdx]; + const s = ndSlices[mappedIdx]; + let sel: SliceSelectionState = { ...defaultSelection(dim.size), mode: 'slice' }; + if (Array.isArray(s)) { + sel = { start: String(s[0]), stop: s[1] !== null ? String(s[1]) : '', scalar: '', mode: 'slice' }; + } + initRows.push({ + id: nextId(), + dimName: dim.name, + sel, + axis + }); + } + } + + if (initRows.length > 0) return initRows; + } + + const activeDims = dims.slice(-Math.min(MAX_ACTIVE_DIMS, dims.length)); + const defaultAxes: Axis[] = ['z', 'y', 'x']; + const axes = defaultAxes.slice(-activeDims.length); + return activeDims.map((d, i) => ({ + id: nextId(), + dimName: d.name, + sel: { ...defaultSelection(d.size), mode: 'slice' }, + axis: axes[i], + })); + }; + + const [rows, setRows] = useState(() => makeInitialRows(availableDims)); + const [collapsedSels, setCollapsedSels] = useState>( + () => makeInitialCollapsedSels(availableDims), + ); + const [lastKey, setLastKey] = useState(dimsKey); + const [collapsedOpen, setCollapsedOpen] = useState(false); + + if (dimsKey !== lastKey) { + setLastKey(dimsKey); + setRows(makeInitialRows(availableDims)); + setCollapsedSels(makeInitialCollapsedSels(availableDims)); + } + + useEffect(()=>{ + setCompress(false) + setCachedChunks(null) + setCached(false) + + if (!meta || !meta.chunks || !meta.shape) { + if (meta && cache.has(`${initStore}_${meta.name}`)) { + setCached(true); + } + return; + } + + const ndSlicesTemp = availableDims.map((d) => { + const activeRow = rows.find((r) => r.dimName === d.name); + if (activeRow) { + return [parseInt(activeRow.sel.start) || 0, parseInt(activeRow.sel.stop) || d.size] as [number, number]; + } + const colSel = collapsedSels[d.name]; + if (colSel && colSel.mode === 'scalar') return parseInt(colSel.scalar) || 0; + return 0; + }); + + const scalarIndices = ndSlicesTemp.filter(s => typeof s === "number").join("_"); + const cacheBase = scalarIndices !== "" ? `${initStore}_${meta.name}_${scalarIndices}` : `${initStore}_${meta.name}`; + + const rowZ = rows.find((r) => r.axis === 'z'); + const rowY = rows.find((r) => r.axis === 'y'); + const rowX = rows.find((r) => r.axis === 'x'); + + const origIdxZ = rowZ ? getOrigIdx(rowZ.dimName) : -1; + const origIdxY = rowY ? getOrigIdx(rowY.dimName) : -1; + const origIdxX = rowX ? getOrigIdx(rowX.dimName) : -1; + + const getSliceDims = (row?: SlicerRow, origIdx?: number) => { + const defaultLast = origIdx !== undefined && origIdx >= 0 ? meta.shape?.[origIdx] ?? 1 : 1; + if (!row) return { first: 0, last: defaultLast }; + const sel = row.sel; + if (sel.mode === 'scalar') { + const val = parseInt(sel.scalar) || 0; + return { first: val, last: val + 1 }; + } + const start = parseInt(sel.start) || 0; + let stop = parseInt(sel.stop); + if (isNaN(stop)) stop = defaultLast; + else stop += 1; + return { first: start, last: stop }; + }; + + const zSlice = getSliceDims(rowZ, origIdxZ); + const ySlice = getSliceDims(rowY, origIdxY); + const xSlice = getSliceDims(rowX, origIdxX); + + const calcDim = (slice: {first: number, last: number}, dimIdx: number) => { + if (dimIdx < 0) return { start: 0, end: 1 }; + const chunkDim = meta.chunks?.[dimIdx]; + if (!chunkDim) return { start: 0, end: 1 }; + const start = Math.floor(slice.first / chunkDim); + return { start, end: Math.ceil(slice.last / chunkDim) }; + }; + + const zDim = calcDim(zSlice, origIdxZ); + const yDim = calcDim(ySlice, origIdxY); + const xDim = calcDim(xSlice, origIdxX); + + let accum = 0; + let total = 0; + for (let z = zDim.start; z < zDim.end; z++) { + for (let y = yDim.start; y < yDim.end; y++) { + for (let x = xDim.start; x < xDim.end; x++) { + total++; + const chunkID = `z${z}_y${y}_x${x}`; + const cacheName = `${cacheBase}_chunk_${chunkID}`; + if (cache.has(cacheName)) { + accum++; + } + } + } + } + + if (total > 0 && accum > 0) { + setCachedChunks(`${accum}/${total}`); + setCached(true); + } else if (cache.has(`${initStore}_${meta.name}`)) { + setCached(true); + } + }, [meta, cache, initStore, setCompress, rows, collapsedSels, availableDims]) + + const sizeData = useMemo(() => { + const rowZ = rows.find((r) => r.axis === 'z'); + const rowY = rows.find((r) => r.axis === 'y'); + const rowX = rows.find((r) => r.axis === 'x'); + + const is2D = dataShape.length === 2 || !rowZ; + + const getSliceDims = (row?: SlicerRow, defaultLast = 0) => { + if (!row) return { first: 0, last: defaultLast, steps: defaultLast }; + const sel = row.sel; + if (sel.mode === 'scalar') { + const val = parseInt(sel.scalar) || 0; + return { first: val, last: val + 1, steps: 1 }; + } + const start = parseInt(sel.start) || 0; + let stop = parseInt(sel.stop); + if (isNaN(stop)) stop = defaultLast; + else stop += 1; + return { first: start, last: stop, steps: Math.max(1, stop - start) }; + }; + + const origIdxZ = rowZ ? getOrigIdx(rowZ.dimName) : -1; + const origIdxY = rowY ? getOrigIdx(rowY.dimName) : -1; + const origIdxX = rowX ? getOrigIdx(rowX.dimName) : -1; + + const lenZ = origIdxZ >= 0 ? dataShape[origIdxZ] : 1; + const lenY = origIdxY >= 0 ? dataShape[origIdxY] : 1; + const lenX = origIdxX >= 0 ? dataShape[origIdxX] : 1; + + const z = is2D ? { first: 0, last: 1, steps: 1 } : getSliceDims(rowZ, lenZ); + const y = getSliceDims(rowY, lenY); + const x = getSliceDims(rowX, lenX); + + const maxSizeLimit = is2D ? maxTextureSize : max3DTextureSize; + const texCounts = [z.steps / maxSizeLimit, y.steps / maxSizeLimit, x.steps / maxSizeLimit]; + + const depths = texCounts.some(count => count > 1) + ? texCounts.map(val => Math.ceil(val)) + : [1, 1, 1]; + + const thisCount = texCounts.reduce((prod, val) => prod * Math.ceil(val), 1) + + const getSelSteps = (dimName: string, defaultLast: number) => { + const row = rows.find(r => r.dimName === dimName); + if (row) return getSliceDims(row, defaultLast).steps; + + const collSel = collapsedSels[dimName]; + if (collSel) { + if (collSel.mode === 'scalar') return 1; + const start = parseInt(collSel.start) || 0; + let stop = parseInt(collSel.stop); + if (isNaN(stop)) stop = defaultLast; + else stop += 1; + return Math.max(1, stop - start); + } + return defaultLast; + }; + + const totalSteps = availableDims.reduce((prod, d) => prod * getSelSteps(d.name, d.size), 1); + const sizeRatio = totalSteps / (dataShape.reduce((a, b) => a * b, 1) || 1); + let calculatedSize = (meta.totalSize || 0) * sizeRatio; + + if (!is2D) { + calculatedSize = calculatedSize / (coarsen ? kernelDepth * Math.pow(kernelSize, 2) : 1); + } + + return { size: calculatedSize, thisCount, depths }; + }, [meta, rows, collapsedSels, availableDims, dataShape, chunkShape, coarsen, kernelSize, kernelDepth, maxTextureSize, max3DTextureSize]); + + useEffect(() => { + setTextureArrayDepths(sizeData.depths); + setTexCount(sizeData.thisCount); + setTooBig(sizeData.thisCount > 14); + }, [sizeData, setTextureArrayDepths]); + + const currentSize = sizeData.size; + + const cachedSize = useMemo(()=>{ + const thisDtype = (meta?.dtype as string) || ''; + if (thisDtype.includes("32") || thisDtype.includes("f4")){ + return currentSize / 2; + } else if (thisDtype.includes("64") || thisDtype.includes("f8")){ + return currentSize / 4; + } else if (thisDtype.includes("8") || thisDtype.includes("i1") ){ + return currentSize * 2; + } else { + return currentSize; + } + },[currentSize, meta]) + + const smallCache = cachedSize > cacheSize; + + const firstUnusedDim = (currentRows: SlicerRow[]): string => { + const usedNames = new Set(currentRows.map((r) => r.dimName)); + return availableDims.find((d) => !usedNames.has(d.name))?.name ?? ''; + }; + + const firstUnusedAxis = (currentRows: SlicerRow[]): Axis => { + const used = new Set(currentRows.map((r) => r.axis)); + return (['x', 'y', 'z'] as Axis[]).find((a) => !used.has(a)) ?? 'z'; + }; + + const addRow = () => { + setRows((prev) => { + if (prev.length >= MAX_ACTIVE_DIMS) return prev; + const dimName = firstUnusedDim(prev); + if (!dimName) return prev; + const dim = availableDims.find((d) => d.name === dimName)!; + const newRows: SlicerRow[] = [...prev, { + id: nextId(), + dimName, + sel: { ...defaultSelection(dim.size), mode: 'slice' }, + axis: 'z', // Placeholder, reassigned below + }]; + + const defaultAxes: Axis[] = ['z', 'y', 'x']; + const axes = defaultAxes.slice(-newRows.length); + return newRows.map((r, i) => ({ ...r, axis: axes[i] })); + }); + }; + + const removeLastRow = () => + setRows((prev) => { + const newRows = prev.slice(0, -1); + const defaultAxes: Axis[] = ['z', 'y', 'x']; + const axes = defaultAxes.slice(-newRows.length); + return newRows.map((r, i) => ({ ...r, axis: axes[i] })); + }); + + const updateDimName = (id: number, dimName: string) => { + setRows((prev) => + prev.map((r) => { + if (r.id !== id) return r; + const dim = availableDims.find((d) => d.name === dimName); + return { ...r, dimName, sel: { ...defaultSelection(dim?.size), mode: 'slice' } }; + }), + ); + }; + + const updateSel = (id: number, sel: SliceSelectionState) => + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, sel: { ...sel, mode: 'slice' } } : r))); + + const updateCollapsedSel = (dimName: string, sel: SliceSelectionState) => + setCollapsedSels((prev) => ({ ...prev, [dimName]: { ...sel, mode: 'scalar' } })); + + + + const activeDimNames = new Set(rows.map((r) => r.dimName)); + const collapsedDims = availableDims.filter((d) => !activeDimNames.has(d.name)); + + const atMax = rows.length >= MAX_ACTIVE_DIMS; + const noUnused = firstUnusedDim(rows) === ''; + const canAdd = !atMax && !noUnused; + + const addTooltip = atMax + ? `Maximum of ${MAX_ACTIVE_DIMS} dimensions, remove one before adding another.` + : noUnused + ? 'All dimensions are already active.' + : undefined; + + const handlePlot = () => { + const rowZ = rows.find(r => r.axis === 'z'); + const rowY = rows.find(r => r.axis === 'y'); + const rowX = rows.find(r => r.axis === 'x'); + + const getSliceArray = (row?: SlicerRow, defaultLast = 0): [number, number | null] => { + if (!row) return [0, null]; + const sel = row.sel; + if (sel.mode === 'scalar') { + const val = parseInt(sel.scalar) || 0; + return [val, val + 1]; + } + const start = parseInt(sel.start) || 0; + let stop = parseInt(sel.stop); + return [start, isNaN(stop) ? null : stop + 1]; + }; + + setZSlice(getSliceArray(rowZ, dataShape ? dataShape[getOrigIdx(rowZ?.dimName || '')] : 0)); + setYSlice(getSliceArray(rowY, dataShape ? dataShape[getOrigIdx(rowY?.dimName || '')] : 0)); + setXSlice(getSliceArray(rowX, dataShape ? dataShape[getOrigIdx(rowX?.dimName || '')] : 0)); + + const ndSlices: (number | [number, number | null])[] = availableDims.map((dim) => { + const row = rows.find((r) => r.dimName === dim.name); + if (row) { + if (row.sel.mode === 'scalar') return parseInt(row.sel.scalar) || 0; + const start = parseInt(row.sel.start) || 0; + let stop = parseInt(row.sel.stop); + return [start, isNaN(stop) ? null : stop + 1]; + } + const colSel = collapsedSels[dim.name]; + if (colSel && colSel.mode === 'scalar') return parseInt(colSel.scalar) || 0; + return 0; + }); + + const axisMapping = { + x: getOrigIdx(rowX?.dimName || ''), + y: getOrigIdx(rowY?.dimName || ''), + z: getOrigIdx(rowZ?.dimName || '') + }; + + useZarrStore.getState().setNdSlices(ndSlices); + useZarrStore.getState().setAxisMapping(axisMapping); + + if (collapsedDims.length > 0) { + const firstCollapsed = collapsedDims[0]; + const sel = collapsedSels[firstCollapsed.name]; + if (sel && sel.mode === 'scalar') { + useGlobalStore.getState().setIdx4D(parseInt(sel.scalar) || 0); + } + } + + if (variable === meta.name) { + ReFetch(); + } else { + setMaxSize(cacheSize); + setVariable(meta.name || ''); + ReFetch(); + } + + if (setShowMeta) setShowMeta(false); + if (setOpenVariables) setOpenVariables(false); + usePlotStore.setState({ coarsen, kernel: { kernelDepth, kernelSize } }); + + onApply?.(rows.map((r) => r.sel), rows.map((r) => r.axis), rows.map((r) => r.dimName)); + }; + + return ( +
+
+ {/* Top Header: Name, Attributes, Options, and Plot button */} +
+
+ {`${meta.long_name ?? meta.name ?? ''} `} + {mounted && isMobile ? ( + + + Attributes + + + + Attributes + Metadata Information for variable + +
+
+ {renderAttributes(metadata, defaultAttributes)} +
+
+
+
+ ) : ( + + + Attributes + + + {renderAttributes(metadata, defaultAttributes)} + + + )} +
+ + {/* Options */} +
+ {/* Coarsen Toggle */} +
+ + setCoarsen(e)}/> +
+ + {/* Compress Toggle */} +
+ + setCompress(e)}/> +
+ + {/* Plot Button */} +
+ {!tooBig && } +
+
+ + {/* Status Information */} +
+ {/* Size info badge */} +
+ Raw: {formatBytes(currentSize)} + | + Stored: {compress ? "<" : ""}{formatBytes(cachedSize)} +
+ + {/* Messages */} +
+ {tooBig && + + Too many textures ({texCount}/14). Won't fit. + + } + {cached && + + {cachedChunks ? `${cachedChunks} chunks already cached` : "Already cached"} + + } +
+
+
+ + {/* Coarsen Expand UI */} + +
+
= 3 ? 'visible' : 'hidden' }} + > + Temporal Coarsening +
+ { + const val = parseInt(e.target.value) + setDisplayDepth(e.target.value) + setKernelDepth(Math.pow(2,val)) + }} + /> +
+
+
+ Spatial Coarsening +
+ { + const val = parseInt(e.target.value) + setDisplaySpat(e.target.value) + setKernelSize(Math.pow(2, val)) + }} + /> +
+
+
+ Values represent 2ⁿ +
+
+
+ + {/* Cache expand UI if needed */} + {currentSize > maxSize && ( + + {smallCache ? : } + + {smallCache ? "Selection won't fit in Cache" : "Data Will Fit"} + + +
+ Decrease selection or expand cache size +
+ setCacheSize(maxSize+e[0]*(1024*1024))} + className="flex-1 min-w-0" + /> +
+ setCacheSize(parseInt(e.target.value)*(1024*1024))} + /> + MB + + + + + + Increasing this too far can cause crashes. Mobile users beware + + +
+
+
+
+
+ )} + + {/* Dimension Table */} +
+
+ + + + + + + + + + + + {availableDims.map((dim, originalIndex) => { + const activeRow = rows.find((r) => r.dimName === dim.name); + const sel = activeRow ? activeRow.sel : collapsedSels[dim.name]; + const range = !sel ? '?' : sel.mode === 'scalar' ? sel.scalar || '0' : `${sel.start !== '' ? sel.start : '0'}:${sel.stop !== '' ? sel.stop : ':'}`; + const axis = activeRow ? activeRow.axis : 'c'; + const dataSize = dataShape[originalIndex] ?? '?'; + const chunkSize = chunkShape[originalIndex] ?? '?'; + + return ( + + + + + + + + ); + })} + +
DimAxisSelectionData ShapeChunk Shape
+ {dim.name} + {axis.toUpperCase()}{range}{dataSize}{chunkSize}
+
+
+
+ + {/* DimSlicers Area */} +
+
+

Active Dimensions

+
+ + + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
+
+ + {/* Active slicers */} +
+ {rows.map((row, i) => { + const dim = availableDims.find((d) => d.name === row.dimName); + const isLast = i === rows.length - 1; + return ( + updateDimName(row.id, name)} + onRemove={isLast && rows.length > 1 ? removeLastRow : undefined} + dimSize={dim?.size ?? 0} + selection={row.sel} + axis={row.axis} + onChange={(sel) => updateSel(row.id, sel)} + values={dim?.values} + formatValue={dim?.formatValue} + lockMode="slice" + allowedAxes={['z', 'y', 'x']} + /> + ); + })} +
+ + {/* Collapsed dimensions */} + {collapsedDims.length > 0 && ( +
+ + + {collapsedOpen && ( +
+ {collapsedDims.map((dim) => ( + { }} + dimSize={dim.size} + selection={collapsedSels[dim.name] ?? { ...defaultSelection(dim.size), mode: 'scalar' }} + axis="c" + onChange={(sel) => updateCollapsedSel(dim.name, sel)} + values={dim.values} + formatValue={dim.formatValue} + lockMode="scalar" + /> + ))} +
+ )} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/PlayButton.tsx b/src/components/ui/MainPanel/PlayButton.tsx index d7e68036a..43e2175ac 100644 --- a/src/components/ui/MainPanel/PlayButton.tsx +++ b/src/components/ui/MainPanel/PlayButton.tsx @@ -109,10 +109,11 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s zMeta: state.zMeta, variable: state.variable, }))) - const {reFetch, setZSlice, ReFetch} = useZarrStore(useShallow(state => ({ + const {reFetch, setZSlice, ReFetch, axisMapping} = useZarrStore(useShallow(state => ({ reFetch: state.reFetch, setZSlice: state.setZSlice, - ReFetch: state.ReFetch + ReFetch: state.ReFetch, + axisMapping: state.axisMapping }))) const intervalRef = useRef(null) @@ -122,14 +123,17 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s const [showNextChunk, setShowNextChunk] = useState(false) const [showPrevChunk, setShowPrevChunk] = useState(false) - // TIME SLICE INFO - const timeArray = dimArrays[dimArrays.length-3] - let timeSlice = timeArray?.slice(zSlice[0], zSlice[1] ?? undefined) - const timeLength = timeArray?.length || 1 - let sliceDist = zSlice[1] ? zSlice[1] - zSlice[0] : timeLength - zSlice[0] + const shapeLength = dimArrays?.length || 3; + const zIdx = axisMapping?.z >= 0 ? axisMapping.z : Math.max(0, shapeLength - 3); - if (coarsen && timeSlice) { - timeSlice = coarsenFlatArray(timeSlice, kernel.kernelDepth) + // Z-SLICE INFO + const zArray = dimArrays[zIdx] + let zArraySlice = zArray?.slice(zSlice[0], zSlice[1] ?? undefined) + const zLength = zArray?.length || 1 + let sliceDist = zSlice[1] ? zSlice[1] - zSlice[0] : zLength - zSlice[0] + + if (coarsen && zArraySlice) { + zArraySlice = coarsenFlatArray(zArraySlice, kernel.kernelDepth) sliceDist = Math.floor(sliceDist/kernel.kernelDepth) } @@ -137,13 +141,13 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s const [chunkTimeLength, chunkDivWidth, chunkSize] = useMemo(()=>{ const meta = (zMeta as {name : string, chunks:number[], chunkSize:number}[])?.find(e => e.name === variable) if(meta) { - const chunkTimeSize = meta.chunks[meta.chunks.length - 3] - const tempWidth = (chunkTimeSize / timeLength) * 100 + const chunkTimeSize = meta.chunks[zIdx] ?? meta.chunks[meta.chunks.length - 3] + const tempWidth = (chunkTimeSize / zLength) * 100 const chunkSize = meta.chunkSize return [chunkTimeSize, tempWidth, chunkSize] } return [0,0, 1] - }, [zMeta, variable, timeLength]) + }, [zMeta, variable, zLength, zIdx]) function AdjustCacheSize(){ const {maxSize, setMaxSize, cache} = useCacheStore.getState() @@ -154,7 +158,7 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s } // ANIMATION LOOP useEffect(() => { - if (!timeSlice?.length) return + if (!zArraySlice?.length) return if (animate) { if (previousFPS.current !== fps && intervalRef.current) { clearInterval(intervalRef.current) @@ -183,9 +187,9 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s }, [animate, fps]) // LABELS - const currentLabel = parseLoc(timeSlice?.[Math.round(animProg * sliceDist)], dimUnits[0], true) - const firstLabel = parseLoc(timeSlice?.[0], dimUnits[0], true) - const lastLabel = parseLoc(timeSlice?.[sliceDist-1], dimUnits[0], true) + const currentLabel = parseLoc(zArraySlice?.[Math.round(animProg * sliceDist)], dimUnits[zIdx], true) + const firstLabel = parseLoc(zArraySlice?.[0], dimUnits[zIdx], true) + const lastLabel = parseLoc(zArraySlice?.[sliceDist-1], dimUnits[zIdx], true) // RESET ON FETCH useEffect(()=>{ @@ -230,10 +234,10 @@ const PlayInterFace = ({visible, setKeepOpen, setShowSelf}:{visible : boolean, s
{/* VISUALIZER */} - {(sliceDist < Math.floor(timeLength / (coarsen ? kernel.kernelDepth : 1))) && {firstLabel} { const v = Array.isArray(vals) ? vals[0] : 0 - setAnimProg(v / timeLength) + setAnimProg(v / zLength) previousVal.current = v }} /> diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index c3564fb8f..fe1ec3ef8 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -1,16 +1,18 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { TbVariable } from "react-icons/tb"; +import { Loader2 } from "lucide-react"; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { useShallow } from "zustand/shallow"; import { Separator } from "@/components/ui/separator"; -import MetaDataInfo from "./MetaDataInfo"; +import MetaDimSelector from "./MetaDimSelector"; import { GetDimInfo } from "@/utils/HelperFuncs"; import { GetAttributes } from "@/components/zarr/ZarrLoaderLRU"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Button } from "@/components/ui/button-enhanced"; import { Input } from "../input"; +import { useIsMobile } from "@/hooks/use-mobile"; import { Tooltip, TooltipContent, @@ -20,6 +22,7 @@ import { Dialog, DialogContent, DialogTitle, + DialogDescription, } from "@/components/ui/dialog"; import { Accordion, @@ -30,11 +33,12 @@ import { const Variables = () => { - const [popoverSide, setPopoverSide] = useState<"left" | "top">("left"); + const isMobile = useIsMobile(); + const popoverSide = isMobile ? "top" : "left"; const [openMetaPopover, setOpenMetaPopover] = useState(false); const [showMeta, setShowMeta] = useState(false); - const { variables, zMeta, metadata, initStore, openVariables, setMetadata, setOpenVariables } = useGlobalStore( + const { variables, zMeta, metadata, initStore, openVariables, setMetadata, setOpenVariables } = useGlobalStore( useShallow((state) => ({ variables: state.variables, zMeta: state.zMeta, @@ -46,11 +50,12 @@ const Variables = () => { })) ); - const [dimArrays, setDimArrays] = useState([[0],[0],[0]]); - const [dimUnits, setDimUnits] = useState([null,null,null]); - const [dimNames, setDimNames] = useState(["Default"]); + const [selectedVar, setSelectedVar] = useState(null); + const [isLoadingVar, setIsLoadingVar] = useState(null); + const activeRequest = useRef(null); + const isInteractingWithMeta = useRef(false); const [meta, setMeta] = useState(null); const [query, setQuery] = useState(""); // root *open by default* (collapsible but starts open) @@ -107,46 +112,65 @@ const Variables = () => { } }, [query, tree]); - // Handle variable selection - const handleVariableSelect = (val: string, idx: number) => { - setSelectedVar(val); - GetDimInfo(val).then(e => { - setDimNames(e.dimNames); - setDimArrays(e.dimArrays); - setDimUnits(e.dimUnits); - }); - - if (popoverSide === "left") { - setOpenMetaPopover(true); - } else { - setShowMeta(true); + const handleOpenChange = (open: boolean) => { + if (!open && isInteractingWithMeta.current) { + return; // Do not close if interacting with Meta popovers/portals } + setOpenVariables(open); }; - useEffect(() => { - if (variables && zMeta && selectedVar) { - const relevant = zMeta.find((e: any) => e.name === selectedVar); - if (relevant){ - setMeta({...relevant, dimInfo : {dimArrays, dimNames, dimUnits}}); - GetAttributes(selectedVar).then(e=>setMetadata(e)); + // Handle variable selection + const handleVariableSelect = (val: string, idx: number) => { + if (selectedVar === val && meta?.name === val && metadata) { + if (popoverSide === "left") { + setOpenMetaPopover(true); + } else { + setShowMeta(true); } + return; } - }, [selectedVar, variables, zMeta, dimArrays, dimNames, dimUnits]); - useEffect(()=>{ - setSelectedVar(null); + setIsLoadingVar(val); + setSelectedVar(val); setMeta(null); setMetadata(null); - },[initStore, setMetadata]); + activeRequest.current = val; + + Promise.all([GetDimInfo(val), GetAttributes(val)]).then(([dimInfo, attr]) => { + if (activeRequest.current !== val) return; + + const relevant = zMeta?.find((e: any) => e.name === val); + if (relevant) { + setMeta({ + ...relevant, + dimInfo: { + dimArrays: dimInfo.dimArrays, + dimNames: dimInfo.dimNames, + dimUnits: dimInfo.dimUnits, + }, + }); + } + setMetadata(attr); + setIsLoadingVar(null); + + if (popoverSide === "left") { + setOpenMetaPopover(true); + } else { + setShowMeta(true); + } + }).catch((err) => { + if (activeRequest.current === val) { + setIsLoadingVar(null); + } + console.error("Failed to fetch dimension info or attributes:", err); + }); + }; useEffect(() => { - const handleResize = () => { - setPopoverSide(window.innerWidth < 768 ? "top" : "left"); - }; - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); + setSelectedVar(null); + setMeta(null); + setMetadata(null); + }, [initStore, setMetadata]); // Variable item renderer (keeps separator between variables in same group) const VariableItem = ({ val, idx, arrayLength }: { val: string; idx: number; arrayLength: number }) => { @@ -156,13 +180,15 @@ const Variables = () => { return (
handleVariableSelect(val, idx)} > - {variableName} + {variableName} + {isLoadingVar === val && }
{!isLastItem && }
@@ -248,7 +274,7 @@ const Variables = () => { return ( <> - +
@@ -280,9 +306,20 @@ const Variables = () => { className="max-h-[50vh] overflow-hidden flex flex-col" onInteractOutside={(e) => { const target = e.target as HTMLElement; - // If the click is inside the MetaData popover, do not close - if (target.closest('[data-meta-popover]')) { - e.preventDefault(); + // Prevent the main variable list from closing when interacting with Meta popups/portals. + // We set a flag instead of calling e.preventDefault() so that native text selection still works! + if ( + target.closest('[data-meta-popover]') || + target.closest('.metadata-dialog') || + target.closest('[data-slot="combobox-content"]') || + target.closest('[role="dialog"]') || + target.closest('[role="listbox"]') || + target.closest('[data-radix-popper-content-wrapper]') + ) { + isInteractingWithMeta.current = true; + setTimeout(() => { + isInteractingWithMeta.current = false; + }, 0); } }} > @@ -315,32 +352,41 @@ const Variables = () => { data-meta-popover side="left" align="start" - className="max-h-[80vh] overflow-y-auto w-[300px]" + className="max-h-[80vh] overflow-y-auto w-[350px]" > - {meta && ( - { + // close UI after applying selections + setOpenMetaPopover(false); + setOpenVariables(false); + // future: persist sels/axes to store + console.log('Applied slices', sels, axes); + }} /> )} )} {popoverSide === "top" && ( - - - {} + + + { } + Variables configuration dialog
- {meta && ( - { + setShowMeta(false); + setOpenVariables(false); + console.log('Applied slices', sels, axes); + }} /> )}
diff --git a/src/components/ui/MetaData.tsx b/src/components/ui/MetaData.tsx index 0771add01..056625294 100644 --- a/src/components/ui/MetaData.tsx +++ b/src/components/ui/MetaData.tsx @@ -6,11 +6,18 @@ import './css/MetaData.css' import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + import { Tooltip, TooltipContent, @@ -19,6 +26,7 @@ import { import { Button } from "@/components/ui/button-enhanced" +import { useIsMobile } from "@/hooks/use-mobile" export const defaultAttributes = [ "long_name", @@ -64,37 +72,82 @@ export function renderAttributes( ); }); } -const Metadata = ({ data, variable }: { data: Record, variable: string }) => { +const Metadata = ({ data, variable, isMobile: isMobileProp }: { data: Record, variable: string, isMobile?: boolean }) => { + const isMobileHook = useIsMobile(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + // If isMobileProp is provided, we use it directly (no hydration mismatch). + // Otherwise, we default to false during SSR to match the initial Popover render, and swap after mount. + const isMobile = isMobileProp !== undefined ? isMobileProp : (mounted ? isMobileHook : false); + + const trigger = ( + + ); + + const content = ( +
+
+ {renderAttributes(data, defaultAttributes)} +
+
+ ); + + if (isMobile) { + return ( + + + + + {trigger} + + + + Show Variable Attributes + + + + + Attributes + Metadata Information for variable + + {content} + + + ); + } + return ( - - - - - - - - - Show Variable Attributes - - - - - Attributes - -
-
- {renderAttributes(data, defaultAttributes)} -
-
-
-
+ + + + + {trigger} + + + + Show Variable Attributes + + + +

Attributes

+ {content} +
+
); }; diff --git a/src/components/ui/NavBar/ExportImageSettings.tsx b/src/components/ui/NavBar/ExportImageSettings.tsx index 08e13b3f2..693663571 100644 --- a/src/components/ui/NavBar/ExportImageSettings.tsx +++ b/src/components/ui/NavBar/ExportImageSettings.tsx @@ -11,6 +11,7 @@ import { import { useImageExportStore } from '@/GlobalStates/ImageExportStore'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { usePlotStore } from '@/GlobalStates/PlotStore'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; import { useShallow } from 'zustand/shallow'; import { ChevronDown } from 'lucide-react'; import { @@ -61,12 +62,18 @@ const ExportImageSettings = () => { const [showSettings, setShowSettings] = useState(true) const [previewState, setPreviewState] = useState(false) + const { axisMapping } = useZarrStore(useShallow(state => ({ + axisMapping: state.axisMapping + }))); + useEffect(()=>{ - const timeArray = dimArrays[dimArrays.length-3] - const timeLength = timeArray?.length || 1 - const sliceDist = zSlice[1] ? zSlice[1] - zSlice[0] : timeLength - zSlice[0] - setFrames(sliceDist) - },[zSlice]) + const shapeLength = dimArrays?.length || 3; + const zIdx = axisMapping?.z >= 0 ? axisMapping.z : Math.max(0, shapeLength - 3); + const zArray = dimArrays[zIdx]; + const zLength = zArray?.length || 1; + const sliceDist = zSlice[1] ? zSlice[1] - zSlice[0] : zLength - zSlice[0]; + setFrames(sliceDist); + },[zSlice, dimArrays, axisMapping]) return ( diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index 446206fce..046f6c5bb 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -16,6 +16,8 @@ const globalValues = [ 'initStore', 'storeFromURL', 'variable', + 'colormapName', + 'flipColormap', ] const plotValues = [ @@ -71,12 +73,15 @@ const plotValues = [ 'borderWidth', 'cameraPosition', 'disablePointScale', + 'is360Deg', ] const zarrValues = [ 'zSlice', 'ySlice', 'xSlice', + 'ndSlices', + 'axisMapping', 'compress', 'useNC', // This one is more static and so toggling switch doesn't break all other logic 'fetchNC', diff --git a/src/components/zarr/GetArray.ts b/src/components/zarr/GetArray.ts index c1ded8faf..7dd2efebc 100644 --- a/src/components/zarr/GetArray.ts +++ b/src/components/zarr/GetArray.ts @@ -5,12 +5,12 @@ import { useErrorStore } from "@/GlobalStates/ErrorStore"; import { calculateStrides } from "@/utils/HelperFuncs"; import { ToFloat16, CompressArray, DecompressArray, copyChunkToArray, RescaleArray, copyChunkToArray2D } from "./utils"; import { NCFetcher, zarrFetcher } from "./dataFetchers"; -import { Convolve } from "../computation/webGPU"; +import { Convolve, Convolve2D } from "../computation/webGPU"; import { coarsen3DArray } from "@/utils/HelperFuncs"; export async function GetArray(varOveride?: string) { const { idx4D, initStore, variable, setProgress, setStrides, setStatus } = useGlobalStore.getState(); - const { compress, xSlice, ySlice, zSlice, coarsen, kernelSize, kernelDepth, fetchNC, setCurrentChunks, setArraySize } = useZarrStore.getState(); + const { compress, xSlice, ySlice, zSlice, ndSlices, axisMapping, coarsen, kernelSize, kernelDepth, fetchNC, setCurrentChunks, setArraySize } = useZarrStore.getState(); const { cache } = useCacheStore.getState(); const useNC = initStore.startsWith("local") && fetchNC // In case a user has NetCDF switched but then goes to a remote const fetcher = useNC ? NCFetcher() : zarrFetcher() @@ -18,21 +18,50 @@ export async function GetArray(varOveride?: string) { const meta = await fetcher.getMetadata(targetVariable); const { shape, chunkShape, fillValue, dtype } = meta; const rank = shape.length; - const hasZ = rank >= 3; - const xDimIndex = rank - 1, yDimIndex = rank - 2, zDimIndex = rank - 3; + // Identify which dimensions are already explicitly mapped + const parseMapped = (val: any) => typeof val === 'number' && !isNaN(val) && val >= 0 ? val : -1; + const mappedX = parseMapped(axisMapping.x); + const mappedY = parseMapped(axisMapping.y); + const mappedZ = parseMapped(axisMapping.z); + + const mappedDims = new Set([mappedX, mappedY, mappedZ].filter(v => v >= 0)); + const unmappedDims: number[] = []; + for (let i = rank - 1; i >= 0; i--) { + if (!mappedDims.has(i)) unmappedDims.push(i); + } + + const xDimIndex = mappedX >= 0 ? mappedX : (unmappedDims.length > 0 ? unmappedDims.shift()! : rank - 1); + const yDimIndex = mappedY >= 0 ? mappedY : (unmappedDims.length > 0 ? unmappedDims.shift()! : rank - 2); + const zDimIndex = mappedZ >= 0 ? mappedZ : (unmappedDims.length > 0 ? unmappedDims.shift()! : -1); - const calcDim = (slice: [number, number | null], dimIdx: number) => { // This function provides information for extraction from each dimension of datarray + const hasZ = zDimIndex >= 0; + + const calcDim = (slice: [number, number | null], dimIdx: number) => { if (dimIdx < 0) return { start: 0, end: 1, size: 0, chunkDim: 1 }; const dimSize = shape[dimIdx]; const chunkDim = chunkShape[dimIdx]; const start = Math.floor(slice[0] / chunkDim); const sliceEnd = slice[1] ?? dimSize; - return { start, end: Math.ceil(sliceEnd / chunkDim), size: sliceEnd - slice[0], chunkDim }; + return { start, end: Math.ceil(sliceEnd / chunkDim), size: sliceEnd - slice[0], chunkDim, offset: slice[0] % chunkDim }; + }; + + // If an axis is unmapped in the UI (NaN), we MUST fetch it as a scalar (size 1) + // using the collapsed value from ndSlices. Otherwise, it defaults to [0, null] + // and fetches the entire dimension, leading to massive memory requests (OOM). + const getEffectiveSlice = (mappingIdx: number, dimIdx: number, uiSlice: [number, number | null]): [number, number | null] => { + if (mappingIdx >= 0) return uiSlice; // Axis is mapped, use the UI slice + + if (dimIdx >= 0 && ndSlices && ndSlices[dimIdx] !== undefined) { + const sel = ndSlices[dimIdx]; + if (typeof sel === 'number') return [sel, sel + 1]; + if (Array.isArray(sel)) return sel as [number, number | null]; + } + return [0, 1]; // Safe fallback to scalar }; - const xDim = calcDim(xSlice, xDimIndex); - const yDim = calcDim(ySlice, yDimIndex); - const zDim = calcDim(zSlice, zDimIndex); + const xDim = calcDim(getEffectiveSlice(axisMapping.x, xDimIndex, xSlice), xDimIndex); + const yDim = calcDim(getEffectiveSlice(axisMapping.y, yDimIndex, ySlice), yDimIndex); + const zDim = calcDim(getEffectiveSlice(axisMapping.z, zDimIndex, zSlice), zDimIndex); let outputShape = hasZ ? [zDim.size, yDim.size, xDim.size] : [yDim.size, xDim.size]; if (coarsen) { @@ -40,7 +69,10 @@ export async function GetArray(varOveride?: string) { } const totalElements = outputShape.reduce((a, b) => a * b, 1); - if (totalElements > 1e9) { useErrorStore.getState().setError("largeArray"); throw new Error("Cannot allocate array."); } + if (totalElements > 1e9) { + useErrorStore.getState().setError("largeArray"); + throw new Error("Cannot allocate array. Details printed to console."); + } const destStride = calculateStrides(outputShape); setStrides(destStride); @@ -54,6 +86,9 @@ export async function GetArray(varOveride?: string) { let iter = 1; const rescaleIDs: string[] = []; + const scalarIndices = (ndSlices && ndSlices.length > 0) ? ndSlices.filter(s => typeof s === "number").join("_") : (idx4D ?? ""); + const cacheBase = scalarIndices !== "" ? `${initStore}_${targetVariable}_${scalarIndices}` : `${initStore}_${targetVariable}`; + setStatus("Downloading..."); setProgress(0); @@ -61,7 +96,6 @@ export async function GetArray(varOveride?: string) { for (let y = yDim.start; y < yDim.end; y++) { for (let x = xDim.start; x < xDim.end; x++) { const chunkID = `z${z}_y${y}_x${x}`; - const cacheBase = rank > 3 ? `${initStore}_${targetVariable}_${idx4D}` : `${initStore}_${targetVariable}`; const cacheName = `${cacheBase}_chunk_${chunkID}`; const cachedChunk = cache.get(cacheName); const isCacheValid = cachedChunk && @@ -78,7 +112,8 @@ export async function GetArray(varOveride?: string) { typedArray, outputShape, destStride as any, [z, y, x], - [zDim.start, yDim.start, xDim.start] + [zDim.chunkDim, yDim.chunkDim, xDim.chunkDim], + [zSlice[0], ySlice[0], xSlice[0]] ) } else { copyChunkToArray2D( @@ -88,21 +123,51 @@ export async function GetArray(varOveride?: string) { typedArray, outputShape, destStride as any, [y, x], - [yDim.start, xDim.start]) + [yDim.chunkDim, xDim.chunkDim], + [ySlice[0], xSlice[0]] + ) } } else { - const raw = await fetcher.fetchChunk({ variable:targetVariable, rank, shape, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D }); + const raw = await fetcher.fetchChunk({ variable:targetVariable, rank, shape, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D, ndSlices, axisMapping }); const rawData = Number.isFinite(fillValue) ? raw.data.map((v: number) => v === fillValue ? NaN : v) : raw.data; // Don't map if no fillvalue let [chunkF16, newScalingFactor] = ToFloat16(rawData, scalingFactor); - let thisShape = raw.shape; - let chunkStride = raw.stride; + + // Determine which indices in raw.shape map to Z, Y, X + const activeDims: number[] = []; + for (let i = 0; i < rank; i++) { + if (ndSlices && ndSlices.length === rank) { + if (i === xDimIndex || i === yDimIndex || i === zDimIndex || Array.isArray(ndSlices[i])) { + activeDims.push(i); + } + } else { + if (i === xDimIndex || i === yDimIndex || i === zDimIndex) { + activeDims.push(i); + } + } + } + + const zIndexInRaw = activeDims.indexOf(zDimIndex); + const yIndexInRaw = activeDims.indexOf(yDimIndex); + const xIndexInRaw = activeDims.indexOf(xDimIndex); + + let thisShape = hasZ ? [raw.shape[zIndexInRaw], raw.shape[yIndexInRaw], raw.shape[xIndexInRaw]] : [raw.shape[yIndexInRaw], raw.shape[xIndexInRaw]]; + let chunkStride = hasZ ? [raw.stride[zIndexInRaw], raw.stride[yIndexInRaw], raw.stride[xIndexInRaw]] : [raw.stride[yIndexInRaw], raw.stride[xIndexInRaw]]; if (coarsen) { - chunkF16 = await Convolve(chunkF16, { shape: chunkShape, strides: chunkStride }, "Mean3D", { kernelSize, kernelDepth }) as Float16Array; - thisShape = thisShape.map((dim, idx) => Math.floor(dim / (idx === 0 ? kernelDepth : kernelSize))); - chunkF16 = coarsen3DArray(chunkF16, chunkShape, chunkStride as any, kernelSize, kernelDepth, thisShape.reduce((a, b) => a * b, 1)); + const origShape = [...thisShape]; + if (hasZ) { + chunkF16 = await Convolve(chunkF16, { shape: origShape, strides: chunkStride }, "Mean3D", { kernelSize, kernelDepth }) as Float16Array; + thisShape = origShape.map((dim, idx) => Math.floor(dim / (idx === 0 ? kernelDepth : kernelSize))); + chunkF16 = coarsen3DArray(chunkF16, origShape as [number, number, number], chunkStride as [number, number, number], kernelSize, kernelDepth, thisShape.reduce((a, b) => a * b, 1)); + } else { + chunkF16 = await Convolve2D(chunkF16, { shape: origShape, strides: chunkStride }, "Mean2D", kernelSize) as Float16Array; + thisShape = origShape.map((dim, idx) => Math.floor(dim / kernelSize)); + const paddedShape = [1, origShape[0], origShape[1]] as [number, number, number]; + const paddedStride = [1, chunkStride[0], chunkStride[1]] as [number, number, number]; + chunkF16 = coarsen3DArray(chunkF16, paddedShape, paddedStride, kernelSize, 1, thisShape.reduce((a, b) => a * b, 1)); + } chunkStride = calculateStrides(thisShape); } @@ -119,16 +184,28 @@ export async function GetArray(varOveride?: string) { } if (hasZ) { - copyChunkToArray(chunkF16, thisShape.slice(-3), chunkStride.slice(-3) as any, typedArray, outputShape, destStride as any, [z, y, x], [zDim.start, yDim.start, xDim.start]); + copyChunkToArray( + chunkF16, thisShape.slice(-3), chunkStride.slice(-3) as any, + typedArray, outputShape, destStride as any, [z, y, x], + [zDim.chunkDim, yDim.chunkDim, xDim.chunkDim], + [zSlice[0], ySlice[0], xSlice[0]] + ); } else { - copyChunkToArray2D(chunkF16, thisShape, chunkStride as any, typedArray, outputShape, destStride as any, [y, x], [yDim.start, xDim.start]); + copyChunkToArray2D( + chunkF16, thisShape, chunkStride as any, + typedArray, outputShape, destStride as any, [y, x], + [yDim.chunkDim, xDim.chunkDim], + [ySlice[0], xSlice[0]] + ); } cache.set(cacheName, { data: compress ? CompressArray(chunkF16, 7) : chunkF16, - shape: chunkShape, stride: chunkStride, + shape: thisShape, stride: chunkStride, scaling: scalingFactor, compressed: compress, coarsened: coarsen, - kernel: { kernelDepth: coarsen ? kernelDepth : undefined, kernelSize: coarsen ? kernelSize : undefined } + kernel: { kernelDepth: coarsen ? kernelDepth : undefined, kernelSize: coarsen ? kernelSize : undefined }, + fullChunkDim: [zDim.chunkDim, yDim.chunkDim, xDim.chunkDim], + sliceStart: [zSlice[0] ?? 0, ySlice[0] ?? 0, xSlice[0] ?? 0] }); rescaleIDs.push(chunkID); } @@ -137,5 +214,5 @@ export async function GetArray(varOveride?: string) { } } setProgress(0); - return { data: typedArray, shape: outputShape, dtype, scalingFactor }; + return { data: typedArray, shape: outputShape, indices: hasZ ? [zDimIndex, yDimIndex, xDimIndex] : [yDimIndex, xDimIndex], dtype, scalingFactor }; } \ No newline at end of file diff --git a/src/components/zarr/NCGetters.ts b/src/components/zarr/NCGetters.ts index 1e0f9e2b9..2e027710f 100644 --- a/src/components/zarr/NCGetters.ts +++ b/src/components/zarr/NCGetters.ts @@ -158,7 +158,8 @@ export async function GetNCArray(variable: string){ outputShape, destStride as [number, number, number], [z,y,x], - [zDim.start,yDim.start,xDim.start], + [zDim.chunkDim,yDim.chunkDim,xDim.chunkDim], + [zSlice[0] ?? 0, ySlice[0] ?? 0, xSlice[0] ?? 0], ) setProgress(Math.round(iter/totalChunksToLoad*100)) // Progress Bar iter ++; @@ -241,7 +242,8 @@ export async function GetNCArray(variable: string){ outputShape, destStride as [number, number, number], [z,y,x], - [zDim.start,yDim.start,xDim.start], + [zDim.chunkDim,yDim.chunkDim,xDim.chunkDim], + [zSlice[0] ?? 0, ySlice[0] ?? 0, xSlice[0] ?? 0], ) else copyChunkToArray2D( chunkF16, @@ -251,7 +253,8 @@ export async function GetNCArray(variable: string){ outputShape, destStride as [number, number], [y,x], - [yDim.start,xDim.start], + [yDim.chunkDim,xDim.chunkDim], + [ySlice[0] ?? 0, xSlice[0] ?? 0], ) const cacheChunk = { data: compress ? CompressArray(chunkF16, 7) : chunkF16, @@ -263,7 +266,9 @@ export async function GetNCArray(variable: string){ kernel: { kernelDepth: coarsen ? kernelDepth : undefined, kernelSize: coarsen ? kernelSize : undefined - } + }, + fullChunkDim: [zDim.chunkDim, yDim.chunkDim, xDim.chunkDim], + sliceStart: [zSlice[0] ?? 0, ySlice[0] ?? 0, xSlice[0] ?? 0] } cache.set(cacheName,cacheChunk) setProgress(Math.round(iter/totalChunksToLoad*100)) // Progress Bar diff --git a/src/components/zarr/ZarrLoaderLRU.ts b/src/components/zarr/ZarrLoaderLRU.ts index c0e6eb124..28ca70e3a 100644 --- a/src/components/zarr/ZarrLoaderLRU.ts +++ b/src/components/zarr/ZarrLoaderLRU.ts @@ -44,6 +44,7 @@ export async function GetZarrMetadata( groupStore: Promise, ): Promise { const group = await groupStore; + if (!group) return []; if (group.store instanceof IcechunkStore) { return getIcechunkMetadata(group as zarr.Group); } @@ -56,6 +57,7 @@ export async function GetTitleDescription( groupStore: Promise, ): Promise { const group = await groupStore; + if (!group) return { title: null, description: null }; if (group.store instanceof IcechunkStore) { return getIcechunkTitleDescription(group as zarr.Group); } diff --git a/src/components/zarr/dataFetchers.ts b/src/components/zarr/dataFetchers.ts index 0b5186a60..5e3f755c7 100644 --- a/src/components/zarr/dataFetchers.ts +++ b/src/components/zarr/dataFetchers.ts @@ -42,6 +42,7 @@ export function zarrFetcher() { return { async getMetadata(variable: string) { const group = await currentStore; + if (!group) throw new Error("Zarr store not initialized"); const tempOutVar = await zarr.open(group.resolve(variable), { kind: "array" }); outVar = tempOutVar if (!outVar.is("number") && !outVar.is("bigint")) { @@ -60,18 +61,47 @@ export function zarrFetcher() { _outVar: outVar, // carry through for fetchChunk } as any; }, - async fetchChunk({ variable, rank, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D }: any): Promise { + async fetchChunk({ rank, shape, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D, variable, ndSlices }: any): Promise { const chunkSlice = new Array(rank).fill(0); - chunkSlice[xDimIndex] = zarr.slice(x * chunkShape[xDimIndex], (x + 1) * chunkShape[xDimIndex]); - chunkSlice[yDimIndex] = zarr.slice(y * chunkShape[yDimIndex], (y + 1) * chunkShape[yDimIndex]); - if (zDimIndex >= 0) chunkSlice[zDimIndex] = zarr.slice(z * chunkShape[zDimIndex], (z + 1) * chunkShape[zDimIndex]); - if (rank >= 4) chunkSlice[0] = idx4D; + if (ndSlices && ndSlices.length === rank) { + for (let i = 0; i < rank; i++) { + if (i === xDimIndex) { + chunkSlice[i] = zarr.slice(x * chunkShape[i], Math.min((x + 1) * chunkShape[i], shape[i])); + } else if (i === yDimIndex) { + chunkSlice[i] = zarr.slice(y * chunkShape[i], Math.min((y + 1) * chunkShape[i], shape[i])); + } else if (i === zDimIndex) { + chunkSlice[i] = zarr.slice(z * chunkShape[i], Math.min((z + 1) * chunkShape[i], shape[i])); + } else { + const sel = ndSlices[i]; + if (Array.isArray(sel)) { + chunkSlice[i] = zarr.slice(sel[0], sel[1]); + } else { + chunkSlice[i] = sel; + } + } + } + } else { + chunkSlice[xDimIndex] = zarr.slice(x * chunkShape[xDimIndex], (x + 1) * chunkShape[xDimIndex]); + chunkSlice[yDimIndex] = zarr.slice(y * chunkShape[yDimIndex], (y + 1) * chunkShape[yDimIndex]); + if (zDimIndex >= 0) { + chunkSlice[zDimIndex] = zarr.slice(z * chunkShape[zDimIndex], (z + 1) * chunkShape[zDimIndex]); + } + if (rank >= 4) { + chunkSlice[0] = idx4D; + } + } const chunk = await fetchWithRetry(() => zarr.get(outVar, chunkSlice), `variable ${variable}`, useGlobalStore.getState().setStatus); if (!chunk || chunk.data instanceof BigInt64Array || chunk.data instanceof BigUint64Array) { throw new Error("BigInt arrays not supported."); } - return { data: chunk.data as Float32Array, shape: chunkShape, stride: chunk.stride }; + + let outShape = chunk.shape; + if (!outShape || outShape.length === 0) { + outShape = chunkShape; // fallback if Zarrita doesn't collapse + } + + return { data: chunk.data as Float32Array, shape: outShape as number[], stride: chunk.stride as number[] }; }, }; } @@ -98,23 +128,50 @@ export function NCFetcher() { return { shape, chunkShape, fillValue, validRange, preScaling, dtype: varInfo.dtype }; }, - async fetchChunk({ rank, shape, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D, variable }: any): Promise { + async fetchChunk({ rank, shape, chunkShape, x, y, z, xDimIndex, yDimIndex, zDimIndex, idx4D, variable, ndSlices }: any): Promise { const starts = new Array(rank).fill(0); const counts = new Array(rank).fill(1); - if (rank > 3) { starts[0] = idx4D; counts[0] = 1; } - starts[xDimIndex] = x * chunkShape[xDimIndex]; - counts[xDimIndex] = Math.min(chunkShape[xDimIndex], shape[xDimIndex] - starts[xDimIndex]); - starts[yDimIndex] = y * chunkShape[yDimIndex]; - counts[yDimIndex] = Math.min(chunkShape[yDimIndex], shape[yDimIndex] - starts[yDimIndex]); - if (zDimIndex >= 0) { - starts[zDimIndex] = z * chunkShape[zDimIndex]; - counts[zDimIndex] = Math.min(chunkShape[zDimIndex], shape[zDimIndex] - starts[zDimIndex]); + if (ndSlices && ndSlices.length === rank) { + for (let i = 0; i < rank; i++) { + if (i === xDimIndex) { + starts[i] = x * chunkShape[i]; + counts[i] = Math.min(chunkShape[i], shape[i] - starts[i]); + } else if (i === yDimIndex) { + starts[i] = y * chunkShape[i]; + counts[i] = Math.min(chunkShape[i], shape[i] - starts[i]); + } else if (i === zDimIndex) { + starts[i] = z * chunkShape[i]; + counts[i] = Math.min(chunkShape[i], shape[i] - starts[i]); + } else { + const sel = ndSlices[i]; + if (Array.isArray(sel)) { + starts[i] = sel[0]; + counts[i] = sel[1] - sel[0]; + } else { + starts[i] = sel; + counts[i] = 1; + } + } + } + } else { + if (rank > 3) { starts[0] = idx4D; counts[0] = 1; } + starts[xDimIndex] = x * chunkShape[xDimIndex]; + counts[xDimIndex] = Math.min(chunkShape[xDimIndex], shape[xDimIndex] - starts[xDimIndex]); + starts[yDimIndex] = y * chunkShape[yDimIndex]; + counts[yDimIndex] = Math.min(chunkShape[yDimIndex], shape[yDimIndex] - starts[yDimIndex]); + if (zDimIndex >= 0) { + starts[zDimIndex] = z * chunkShape[zDimIndex]; + counts[zDimIndex] = Math.min(chunkShape[zDimIndex], shape[zDimIndex] - starts[zDimIndex]); + } } let data = await ncModule.getSlicedVariableArray(variable, starts, counts); - return { data, shape: counts as number[], stride: calculateStrides(counts) }; + // Filter out collapsed dims so shape matches Zarrita behavior + let collapsedShape = counts.filter((c, i) => ndSlices ? (!Array.isArray(ndSlices[i]) && i !== xDimIndex && i !== yDimIndex && i !== zDimIndex ? false : true) : c !== 1); + if (collapsedShape.length === 0) collapsedShape = [1]; + return { data, shape: collapsedShape, stride: calculateStrides(collapsedShape) }; }, }; } @@ -163,6 +220,7 @@ export class ZarrFetcher { async fetchChunk({ rank, + shape, chunkShape, x, y, @@ -171,6 +229,7 @@ export class ZarrFetcher { yDimIndex, zDimIndex, idx4D, + ndSlices }: any): Promise { if (!this.outVar) { await this.init(); @@ -180,13 +239,32 @@ export class ZarrFetcher { } const chunkSlice = new Array(rank).fill(0); - chunkSlice[xDimIndex] = zarr.slice(x * chunkShape[xDimIndex], (x + 1) * chunkShape[xDimIndex]); - chunkSlice[yDimIndex] = zarr.slice(y * chunkShape[yDimIndex], (y + 1) * chunkShape[yDimIndex]); - if (zDimIndex >= 0) { - chunkSlice[zDimIndex] = zarr.slice(z * chunkShape[zDimIndex], (z + 1) * chunkShape[zDimIndex]); - } - if (rank >= 4) { - chunkSlice[0] = idx4D; + if (ndSlices && ndSlices.length === rank) { + for (let i = 0; i < rank; i++) { + if (i === xDimIndex) { + chunkSlice[i] = zarr.slice(x * chunkShape[i], Math.min((x + 1) * chunkShape[i], shape[i])); + } else if (i === yDimIndex) { + chunkSlice[i] = zarr.slice(y * chunkShape[i], Math.min((y + 1) * chunkShape[i], shape[i])); + } else if (i === zDimIndex) { + chunkSlice[i] = zarr.slice(z * chunkShape[i], Math.min((z + 1) * chunkShape[i], shape[i])); + } else { + const sel = ndSlices[i]; + if (Array.isArray(sel)) { + chunkSlice[i] = zarr.slice(sel[0], sel[1]); + } else { + chunkSlice[i] = sel; + } + } + } + } else { + chunkSlice[xDimIndex] = zarr.slice(x * chunkShape[xDimIndex], (x + 1) * chunkShape[xDimIndex]); + chunkSlice[yDimIndex] = zarr.slice(y * chunkShape[yDimIndex], (y + 1) * chunkShape[yDimIndex]); + if (zDimIndex >= 0) { + chunkSlice[zDimIndex] = zarr.slice(z * chunkShape[zDimIndex], (z + 1) * chunkShape[zDimIndex]); + } + if (rank >= 4) { + chunkSlice[0] = idx4D; + } } const chunk = await fetchWithRetry( @@ -198,10 +276,16 @@ export class ZarrFetcher { if (!chunk || chunk.data instanceof BigInt64Array || chunk.data instanceof BigUint64Array) { throw new Error("BigInt arrays not supported."); } + + let outShape = chunk.shape; + if (!outShape || outShape.length === 0) { + outShape = chunkShape; // fallback if Zarrita doesn't collapse + } + return { data: chunk.data as Float32Array, - shape: chunkShape, - stride: chunk.stride, + shape: outShape as number[], + stride: chunk.stride as number[], }; } } \ No newline at end of file diff --git a/src/components/zarr/utils.ts b/src/components/zarr/utils.ts index 820b2b21d..d02821ebd 100644 --- a/src/components/zarr/utils.ts +++ b/src/components/zarr/utils.ts @@ -52,6 +52,14 @@ export function ToFloat16( maxVal = val; } } + if (maxVal === 0) { + const newArray = new Float16Array(array.length); + for (let i = 0; i < array.length; i++) { + newArray[i] = array[i] === 0 ? 0 : NaN; + } + return [newArray, initialScale !== 0 ? initialScale : null]; + } + const additionalScaling = Math.ceil(Math.log10(maxVal / 65504)); const needsRescale = additionalScaling > 0 || additionalScaling <= -6; //I think this is complicating things. Because if it was already scaled then there should already by enough variance in the data it doesn't need to go further @@ -195,49 +203,45 @@ export function copyChunkToArray( destShape: number[], destStride: number[], chunkGridPos: number[], - chunkGridStart: number[], + fullChunkDim: number[], + sliceStart: number[], ): void { const [z, y, x] = chunkGridPos; - const [zStartIdx, yStartIdx, xStartIdx] = chunkGridStart; - const [chunkShapeZ, chunkShapeY, chunkShapeX] = chunkShape; + const [chunkDimZ, chunkDimY, chunkDimX] = fullChunkDim; + const [sliceStartZ, sliceStartY, sliceStartX] = sliceStart; const [destShapeZ, destShapeY, destShapeX] = destShape; - // 1. Calculate the local coordinates of the chunk within the destination grid - const localZ = z - zStartIdx; - const localY = y - yStartIdx; - const localX = x - xStartIdx; - - // 2. Determine the starting element position for this chunk in the destination array - const zStart = localZ * chunkShapeZ; - const yStart = localY * chunkShapeY; - const xStart = localX * chunkShapeX; - - // 3. Calculate the actual number of elements to copy for this chunk - // This prevents writing past the end of the destination array for partial chunks. - const zLimit = Math.min(chunkShapeZ, destShapeZ - zStart); - const yLimit = Math.min(chunkShapeY, destShapeY - yStart); - const xLimit = Math.min(chunkShapeX, destShapeX - xStart); - - // 4. Loop using the calculated limits and copy row by row - for (let cz = 0; cz < zLimit; cz++) { - for (let cy = 0; cy < yLimit; cy++) { - // Offset to the start of the row in the SOURCE chunk data - const sourceRowOffset = cz * chunkStride[0] + cy * chunkStride[1]; - - // Offset to the start of the row in the DESTINATION typedArray - const destRowOffset = - (zStart + cz) * destStride[0] + - (yStart + cy) * destStride[1] + - xStart; + const absZ = z * chunkDimZ; + const absY = y * chunkDimY; + const absX = x * chunkDimX; - // Get the row of data from the source chunk, using the new xLimit - const rowData = chunkData.subarray( - sourceRowOffset, - sourceRowOffset + xLimit, - ); + const czStart = Math.max(0, sliceStartZ - absZ); + const czEnd = Math.min(chunkShape[0], sliceStartZ + destShapeZ - absZ); + const cyStart = Math.max(0, sliceStartY - absY); + const cyEnd = Math.min(chunkShape[1], sliceStartY + destShapeY - absY); + const cxStart = Math.max(0, sliceStartX - absX); + const cxEnd = Math.min(chunkShape[2], sliceStartX + destShapeX - absX); - // Place the row in the correct position in the final array - destArray.set(rowData, destRowOffset); + for (let cz = czStart; cz < czEnd; cz++) { + for (let cy = cyStart; cy < cyEnd; cy++) { + const sourceRowOffset = cz * chunkStride[0] + cy * chunkStride[1]; + const destZ = (absZ + cz) - sliceStartZ; + const destY = (absY + cy) - sliceStartY; + const destXStart = (absX + cxStart) - sliceStartX; + const destRowOffset = destZ * destStride[0] + destY * destStride[1] + destXStart; + + if (chunkStride[2] === 1) { + const rowData = chunkData.subarray( + sourceRowOffset + cxStart, + sourceRowOffset + cxEnd, + ); + destArray.set(rowData, destRowOffset); + } else { + for (let cx = cxStart; cx < cxEnd; cx++) { + const destX = (absX + cx) - sliceStartX; + destArray[destZ * destStride[0] + destY * destStride[1] + destX] = chunkData[sourceRowOffset + cx * chunkStride[2]]; + } + } } } } @@ -250,45 +254,40 @@ export function copyChunkToArray2D( destShape: number[], destStride: number[], chunkGridPos: number[], - chunkGridStart: number[], + fullChunkDim: number[], + sliceStart: number[], ): void { - // Destructure the 2D properties const [y, x] = chunkGridPos; - const [yStartIdx, xStartIdx] = chunkGridStart; - const [chunkShapeY, chunkShapeX] = chunkShape; + const [chunkDimY, chunkDimX] = fullChunkDim; + const [sliceStartY, sliceStartX] = sliceStart; const [destShapeY, destShapeX] = destShape; - // 1. Calculate the local coordinates of the chunk within the destination grid - const localY = y - yStartIdx; - const localX = x - xStartIdx; - - // 2. Determine the starting element position for this chunk in the destination array - const yStart = localY * chunkShapeY; - const xStart = localX * chunkShapeX; + const absY = y * chunkDimY; + const absX = x * chunkDimX; - // 3. Calculate the actual number of elements to copy for this chunk. - // This prevents writing past the end of the destination array for partial chunks. - const yLimit = Math.min(chunkShapeY, destShapeY - yStart); - const xLimit = Math.min(chunkShapeX, destShapeX - xStart); + const cyStart = Math.max(0, sliceStartY - absY); + const cyEnd = Math.min(chunkShape[0], sliceStartY + destShapeY - absY); + const cxStart = Math.max(0, sliceStartX - absX); + const cxEnd = Math.min(chunkShape[1], sliceStartX + destShapeX - absX); - // 4. Loop through the rows (Y-axis) and copy each one - for (let cy = 0; cy < yLimit; cy++) { - // Offset to the start of the row in the SOURCE chunk data - // chunkStride[0] is the stride for the Y-dimension + for (let cy = cyStart; cy < cyEnd; cy++) { const sourceRowOffset = cy * chunkStride[0]; + const destY = (absY + cy) - sliceStartY; + const destXStart = (absX + cxStart) - sliceStartX; + const destRowOffset = destY * destStride[0] + destXStart; - // Offset to the start of the row in the DESTINATION typedArray - // destStride[0] is the stride for the Y-dimension - const destRowOffset = (yStart + cy) * destStride[0] + xStart; - - // Get the row of data from the source chunk, using the calculated xLimit - const rowData = chunkData.subarray( - sourceRowOffset, - sourceRowOffset + xLimit, - ); - - // Place the row in the correct position in the final destination array - destArray.set(rowData, destRowOffset); + if (chunkStride[1] === 1) { + const rowData = chunkData.subarray( + sourceRowOffset + cxStart, + sourceRowOffset + cxEnd, + ); + destArray.set(rowData, destRowOffset); + } else { + for (let cx = cxStart; cx < cxEnd; cx++) { + const destX = (absX + cx) - sliceStartX; + destArray[destY * destStride[0] + destX] = chunkData[sourceRowOffset + cx * chunkStride[1]]; + } + } } } diff --git a/src/hooks/useDataFetcher.tsx b/src/hooks/useDataFetcher.tsx index 850a61105..5bb390232 100644 --- a/src/hooks/useDataFetcher.tsx +++ b/src/hooks/useDataFetcher.tsx @@ -75,6 +75,9 @@ export const useDataFetcher = () => { //---- Main Fetch ----// GetArray().then((result) => { const shape = result.shape.filter((val) => val != 1); + const activeIndices = result.indices.filter((_, idx) => result.shape[idx] != 1); + useGlobalStore.getState().setActiveIndices(activeIndices); + const [tempTexture, scaling] = ArrayToTexture({ data: result.data, shape @@ -89,7 +92,7 @@ export const useDataFetcher = () => { if (shapeLength === 2) { setIsFlat(true); if (!["flat", "sphere"].includes(plotType)) { - setPlotType("sphere"); + setPlotType("flat"); } } else { setIsFlat(false); diff --git a/src/hooks/usePaddedTextures.ts b/src/hooks/usePaddedTextures.ts new file mode 100644 index 000000000..f410d725c --- /dev/null +++ b/src/hooks/usePaddedTextures.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; + +type TextureArray = THREE.Data3DTexture[] | THREE.DataTexture[] | null | undefined; + +export const usePaddedTextures = (textures: TextureArray, length: number = 14) => { + return useMemo(() => { + return Array.from({ length }, (_, idx) => textures?.[idx] ?? textures?.[0]); + }, [textures, length]); +}; diff --git a/src/utils/HelperFuncs.ts b/src/utils/HelperFuncs.ts index e3df047ae..63b251af0 100644 --- a/src/utils/HelperFuncs.ts +++ b/src/utils/HelperFuncs.ts @@ -73,7 +73,7 @@ const months = [ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]; -export function parseLoc(input:number, units: string | undefined, verbose: boolean = false) { +export function parseLoc(input: any, units: string | undefined, verbose: boolean = false) { if (!units){ if (typeof(input) == 'bigint'){ return input; @@ -92,28 +92,46 @@ export function parseLoc(input:number, units: string | undefined, verbose: boole const [scale, offset] = parseTimeUnit(units) const timeStamp = Number(input) * scale; const date = new Date(timeStamp + offset); + + const day = date.getUTCDate(); + const month = date.getUTCMonth() + 1; // Months are 0-indexed + const year = date.getUTCFullYear(); + const hours = date.getUTCHours(); + const mins = date.getUTCMinutes(); + const secs = date.getUTCSeconds(); + + const lowerUnits = units.toLowerCase(); + const showTime = lowerUnits.includes('hour') || lowerUnits.includes('min') || lowerUnits.includes('sec') || hours !== 0 || mins !== 0 || secs !== 0; + if (verbose) { - return `${date.getDate()} ${months[date.getMonth()]} ${date.getFullYear()}`; // e.g., "18 Aug 2025" + let dateStr = `${day} ${months[month - 1]} ${year}`; + if (showTime) { + dateStr += ` ${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; + if (secs !== 0 || lowerUnits.includes('sec')) dateStr += `:${String(secs).padStart(2, '0')}`; + } + return dateStr; } else { - const day = date.getDate(); - const month = date.getMonth() + 1; // Months are 0-indexed - const year = date.getFullYear(); - return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`; // e.g., "18-8-2025" + let dateStr = `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`; + if (showTime) { + dateStr += ` ${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; + if (secs !== 0 || lowerUnits.includes('sec')) dateStr += `:${String(secs).padStart(2, '0')}`; + } + return dateStr; } } catch{ return input; } } - if ( units.match(/(degree|degrees|deg|°)/i) ){ - if (input){ - return `${input.toFixed(2)}°` + if ( units && units.match(/(degree|degrees|deg|°)/i) ){ + if (input !== undefined && input !== null){ + return `${Number(input).toFixed(2)}°` } else{ return input } } else { - return input ? input.toFixed(2) : input; + return (input !== undefined && input !== null && typeof input !== 'string') ? Number(input).toFixed(2) : input; } } @@ -179,27 +197,37 @@ export function linspace(start: number, stop: number, num: number): number[] { return Array.from({ length: num }, (_, i) => start + step * i); } -export function ParseExtent(dimUnits: string[], dimArrays: number[][]){ +export function ParseExtent(dimUnits: string[], dimArrays: any[][]){ const {setLonExtent, setLatExtent, setLonResolution, setLatResolution, setOriginalExtent } = usePlotStore.getState(); const {xSlice, ySlice} = usePlotStore.getState(); - const tempUnits = dimUnits.length > 2 ? dimUnits.slice(1) : dimUnits; + const {axisMapping} = useZarrStore.getState(); + const shapeLength = dimArrays.length; + const xIdx = axisMapping.x >= 0 ? axisMapping.x : shapeLength - 1; + const yIdx = axisMapping.y >= 0 ? axisMapping.y : shapeLength - 2; + let tryParse = false; - for (const unit of tempUnits){ - if (!unit) continue; - if (unit.match(/(degree|degrees|deg|°)/i)){ + const xUnit = dimUnits[xIdx]; + const yUnit = dimUnits[yIdx]; + + if ((xUnit && xUnit.match(/(degree|degrees|deg|°)/i)) || (yUnit && yUnit.match(/(degree|degrees|deg|°)/i))) { tryParse = true; - break; - } } + if (tryParse){ - const tempArrs = dimArrays.length > 2 ? dimArrays.slice(1) : dimArrays - const minLat = tempArrs[0][ySlice[0]] - const maxLat = tempArrs[0][ySlice[1]??tempArrs[0].length-1] - let minLon = tempArrs[1][xSlice[0]] - let maxLon = tempArrs[1][xSlice[1]?? tempArrs[1].length-1] + const xArray = dimArrays[xIdx] || []; + const yArray = dimArrays[yIdx] || []; + + const minLat = Number(yArray[ySlice[0]]); + const maxLatIdx = ySlice[1] !== null ? ySlice[1] - 1 : yArray.length - 1; + const maxLat = Number(yArray[maxLatIdx]); + + let minLon = Number(xArray[xSlice[0]]); + const maxLonIdx = xSlice[1] !== null ? xSlice[1] - 1 : xArray.length - 1; + let maxLon = Number(xArray[maxLonIdx]); + if (maxLon > 180){ maxLon -= 180 - minLon -=180 + minLon -= 180 usePlotStore.setState({is360Deg:true}) } else{ usePlotStore.setState({is360Deg:false}) @@ -207,8 +235,8 @@ export function ParseExtent(dimUnits: string[], dimArrays: number[][]){ setLonExtent([minLon, maxLon]) setLatExtent([minLat, maxLat]) - const latRes = Math.abs(tempArrs[0][1] - tempArrs[0][0]) - const lonRes = Math.abs(tempArrs[1][1] - tempArrs[1][0]) + const latRes = Math.abs(Number(yArray[1] ?? 0) - Number(yArray[0] ?? 0)) || 1; + const lonRes = Math.abs(Number(xArray[1] ?? 0) - Number(xArray[0] ?? 0)) || 1; setLonResolution(lonRes) setLatResolution(latRes) setOriginalExtent(new THREE.Vector4(minLon, maxLon, minLat, maxLat)) @@ -259,13 +287,16 @@ function DecompressArray(compressed : Uint8Array){ export function GetCurrentArray(overrideStore?:string){ const { variable, is4D, idx4D, initStore, strides, dataShape, setStatus }= useGlobalStore.getState() - const { arraySize, currentChunks } = useZarrStore.getState() + const { arraySize, currentChunks, ndSlices } = useZarrStore.getState() const {cache} = useCacheStore.getState(); const store = overrideStore ? overrideStore : initStore - if (cache.has(is4D ? `${store}_${idx4D}_${variable}` : `${store}_${variable}`)){ - const chunk = cache.get(is4D ? `${store}_${idx4D}_${variable}` : `${store}_${variable}`) - const compressed = chunk.compressed + const scalarIndices = (ndSlices && ndSlices.length > 0) ? ndSlices.filter(s => typeof s === "number").join("_") : (idx4D ?? ""); + const cacheBase = scalarIndices !== "" ? `${store}_${variable}_${scalarIndices}` : `${store}_${variable}`; + + if (cache.has(cacheBase)){ + const chunk = cache.get(cacheBase) + const compressed = chunk?.compressed setStatus(compressed ? "Decompressing data..." : null) const thisData = compressed ? DecompressArray(chunk.data) : chunk.data setStatus(null) @@ -281,8 +312,9 @@ export function GetCurrentArray(overrideStore?:string){ for (let y = yStartIdx; y < yEndIdx; y++) { for (let x = xStartIdx; x < xEndIdx; x++) { const chunkID = `z${z}_y${y}_x${x}` - const cacheName = is4D ? `${store}_${variable}_${idx4D}_chunk_${chunkID}` : `${store}_${variable}_chunk_${chunkID}` + const cacheName = `${cacheBase}_chunk_${chunkID}` const chunk = cache.get(cacheName) + if (!chunk) continue; const compressed = chunk.compressed const thisData = compressed ? DecompressArray(chunk.data) : chunk.data copyChunkToArray( @@ -293,7 +325,8 @@ export function GetCurrentArray(overrideStore?:string){ dataShape, strides as [number, number, number], [z, y, x], - [zStartIdx, yStartIdx, xStartIdx] + chunk.fullChunkDim || [1, 1, 1], + chunk.sliceStart || [0, 0, 0] ) } }