diff --git a/src/app/globals.css b/src/app/globals.css index 1c4604b8..dbc20fe0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -455,4 +455,12 @@ a[href]:hover::after { .glow-effect { animation: glow-pulse 2s ease-in-out infinite; border-radius: 0.275rem; +} + +input[type=number].no-spinner { + -moz-appearance: textfield; +} + +input[type=number].no-spinner::-moz-number-spin-box { + display: none; } \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx new file mode 100644 index 00000000..7b3b247a --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -0,0 +1,374 @@ +'use client'; +import React, { useState } from 'react'; +import { Slider } from '@/components/ui/slider'; + +import { DimSlicerAxisToggle } from './DimSlicerAxisToggle'; +import { DimSlicerModeToggle } from './DimSlicerModeToggle'; +import { DimSlicerNumericControl } from './DimSlicerNumericControl'; +import { DimSlicerTimeControl } from './DimSlicerTimeControl'; + +export type SelectionMode = 'scalar' | 'slice'; +export type Axis = 'x' | 'y' | 'z' | 'c'; + +export interface SliceSelectionState { + mode: SelectionMode; + scalar: string; + start: string; + stop: string; +} + +export function defaultSelection(dimSize?: number): SliceSelectionState { + const maxIndex = dimSize ? Math.max(dimSize - 1, 0) : 0; + return { mode: 'slice', scalar: '0', start: '0', stop: String(maxIndex) }; +} + +const MODE_ACCENT: Record = { + scalar: 'border-l-teal-700', + slice: 'border-l-[#644FF0]', +}; + +export interface DimSlicerProps { + /** Dimension name, e.g. "time" or "dim_0" */ + dimName: string; + /** Size of this dimension */ + dimSize: number; + /** Current selection state */ + selection: SliceSelectionState; + /** Called whenever the selection changes */ + onChange: (next: SliceSelectionState) => void; + /** Step size for the slider (optional, defaults to 1) */ + step?: number; + /** Selected axis (optional, defaults to 'x') */ + axis?: Axis; + /** Called when axis changes */ + onAxisChange?: (axis: Axis) => void; + /** Array of actual values for this dimension (optional, if provided, dimSize should match values.length) */ + values?: number[]; + /** Function to format values for display (optional) */ + formatValue?: (value: number) => string; +} + +const DimSlicer: React.FC = ({ + dimName, + dimSize, + selection, + onChange, + step = 1, + axis: propAxis = 'x', + onAxisChange, + values, + formatValue, +}) => { + const [currentAxis, setCurrentAxis] = useState(propAxis); + const effectiveDimSize = values ? values.length : dimSize; + const sel = selection ?? defaultSelection(effectiveDimSize); + + const getIndexFromValue = (val: number): number => { + if (!values) return val; + let closestIndex = 0; + let minDiff = Math.abs(values[0] - val); + + for (let i = 1; i < values.length; i++) { + const diff = Math.abs(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 changeScalarBy = (delta: number) => { + let val = parseOr(sel.scalar, 0) + delta; + val = clamp(val, 0, Math.max(effectiveDimSize - 1, 0)); + onChange({ ...sel, scalar: String(val) }); + }; + + const maxIndex = Math.max(effectiveDimSize - 1, 0); + + 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) => { + onChange({ ...sel, ...patch }); + }; + + 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 = (index: number) => + values && effectiveDimSize > 0 && index < values.length + ? String(formatValue ? formatValue(values[index]) : values[index].toString()) + : String(index); + + const isTimeDimension = dimName.toLowerCase().includes('time'); + const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date'); + const showTimeControls = Boolean(values && isTimeDimension); + + return ( +
+ + {/* Top row: dim name + mode toggle + axis toggle (always shown above slider) */} +
+ {dimName} +
+ updateSelection({ mode })} /> + {sel.mode === 'slice' && ( + { + setCurrentAxis(axis); + onAxisChange?.(axis); + }} + /> + )} +
+
+ + {/* Slider */} + {sel.mode === 'slice' && ( +
+ + updateSelection({ start: String(newStart), stop: String(newStop) }) + } + className="w-full cursor-pointer" + /> +
+ )} + + {sel.mode === 'scalar' && ( +
+ updateSelection({ scalar: String(val) })} + className="w-full [&_[data-slot=slider-range]]:bg-transparent cursor-pointer" + /> +
+ )} + + {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 00000000..7d23b914 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -0,0 +1,72 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type Axis = 'x' | 'y' | 'z' | 'c'; + +interface DimSlicerAxisToggleProps { + axis: Axis; + onAxisChange?: (axis: Axis) => void; +} + +export const DimSlicerAxisToggle: React.FC = ({ axis, onAxisChange }) => { + 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); + }, []); + + const axisOptions: Axis[] = ['x', 'y', 'z', 'c']; + + const axisClass = axis === 'x' + ? 'text-pink-500' + : axis === 'y' + ? 'text-green-500' + : axis === 'z' + ? 'text-blue-500' + : 'text-yellow-500'; + + return ( +
+ {expanded ? ( + + {axisOptions.map(a => { + const buttonClass = a === 'x' ? 'text-pink-500' : a === 'y' ? 'text-green-500' : a === 'z' ? 'text-blue-500' : 'text-yellow-500'; + return ( + + ); + })} + + ) : ( + + )} +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx new file mode 100644 index 00000000..30a15f4a --- /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'; +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 00000000..2a9494b6 --- /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 00000000..5adab2b9 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -0,0 +1,78 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +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 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 ( +
+ + {showInput && ( + onValueChange(e.target.value)} + 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 00000000..af9e6285 --- /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 00000000..a878913e --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,98 @@ +'use client' +import React, { useState } 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 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 = values.map((_, i) => ({ label: formattedValue(i), index: i })) + + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + + const filtered = + normalizedInput === '' || normalizedInput === selectedQuery + ? labeledValues + : labeledValues.filter(({ label }) => label.toLowerCase().includes(normalizedInput)) + + const targetWidth = Math.min( + Math.max(Math.max(selectedLabel.length, placeholder.length) + 2, 12), + 20 + ) + + return ( + + setInputQuery(typeof value === 'string' ? value : String(value)) + } + autoHighlight + > + + + {filtered.length === 0 ? No items found. : null} + + {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 00000000..0622ada3 --- /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/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx new file mode 100644 index 00000000..1981c587 --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,236 @@ +"use client"; + +import React, { useMemo, useState } from 'react'; +import DimSlicer, { Axis, defaultSelection, SliceSelectionState } from '@/components/ui/DimSlicer'; +import Metadata, { defaultAttributes, renderAttributes } from "@/components/ui/MetaData" +import { Button } from '@/components/ui/button'; +import { useGlobalStore } from '@/GlobalStates/GlobalStore'; +import { useShallow } from 'zustand/shallow'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { parseLoc } from '@/utils/HelperFuncs'; +import {Popover, PopoverTrigger, PopoverContent} from "@/components/ui/popover" +import { Badge } from "@/components/ui" + +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[]; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[]) => void; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +function defaultAxisForIndex(idx: number, total: number): Axis { + if (total <= 1) return 'c'; + if (total === 2) return idx === 0 ? 'y' : 'x'; + if (total === 3) return idx === 0 ? 'z' : idx === 1 ? 'y' : 'x'; + return (['c', 'z', 'y', 'x'] as Axis[])[idx] ?? 'c'; +} + +function selectionSummary(sels: SliceSelectionState[]): string { + const parts = sels.map((sel) => { + if (sel.mode === 'scalar') return sel.scalar || '0'; + const start = sel.start !== '' ? sel.start : '0'; + const stop = sel.stop !== '' ? sel.stop : ':'; + return `${start}:${stop}`; + }); + return `[ ${parts.join(', ')} ]`; +} + +export default function MetaDimSelector({ meta, metadata, onApply }: Props) { + const rawDimArrays = meta?.dimInfo?.dimArrays ?? []; + const rawDimNames = meta?.dimInfo?.dimNames ?? []; + const rawDimUnits = meta?.dimInfo?.dimUnits ?? []; + const dataShape = meta?.shape + const chunkShape = meta?.chunks + + const dimArrays: number[][] = useMemo( + () => rawDimArrays.map((a) => Array.from(a)), + [rawDimArrays], + ); + const dimUnits: string[] = useMemo( + () => rawDimUnits.map((u) => u ?? ''), + [rawDimUnits], + ); + const dimNames: string[] = rawDimNames; + + const { setDimArrays, setDimNames, setDimUnits } = useGlobalStore( + useShallow((state) => ({ + setDimArrays: state.setDimArrays, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, + })), + ); + + React.useEffect(() => { + setDimArrays(dimArrays); + setDimNames(dimNames); + setDimUnits(dimUnits); + }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); + + const DIMS = useMemo( + () => + dimArrays.map((values, idx) => ({ + name: dimNames[idx] ?? `dim${idx}`, + size: values.length, + values, + formatValue: (i: number): string => + String(parseLoc(values[i] ?? i, dimUnits[idx] || undefined)), + })), + [dimArrays, dimNames, dimUnits], + ); + + const dimsKey = DIMS.map((d) => `${d.name}:${d.size}`).join('|'); + + const [sels, setSels] = useState(() => + DIMS.map((d) => defaultSelection(d.size)), + ); + const [axes, setAxes] = useState(() => + DIMS.map((_, i) => defaultAxisForIndex(i, DIMS.length)), + ); + + const [lastKey, setLastKey] = useState(dimsKey); + if (dimsKey !== lastKey) { + setLastKey(dimsKey); + setSels(DIMS.map((d) => defaultSelection(d.size))); + setAxes(DIMS.map((_, i) => defaultAxisForIndex(i, DIMS.length))); + } + + const selUpdaters = useMemo( + () => DIMS.map((_, i) => (next: SliceSelectionState) => + setSels((prev) => { + const copy = prev.slice(); + copy[i] = next; + return copy; + }) + ), + [dimsKey], + ); + + const axisUpdaters = useMemo( + () => DIMS.map((_, i) => (axis: Axis) => + setAxes((prev) => { + const copy = prev.slice(); + copy[i] = axis; + return copy; + }) + ), + [dimsKey], + ); + + const summary = useMemo(() => selectionSummary(sels), [sels]); + + return ( + <> + {`${meta.long_name} `} + + + + Attributes + + + + {renderAttributes(metadata, defaultAttributes)} + + +
+ {/* e.g. temperature [ 0:364, 0:47, -90:89 ] */} +
+ {'selection'} {summary} +
+ + {/* e.g. (time, z, 0), (lon, x, 1), (lat, y, 2) */} +
+ {DIMS.map((dim, i) => ( + + ( + {dim.name} + ,{' '} + {axes[i]} + ,{' '} + {i} + ) + + ))} +
+ +
+
+ Data Shape + {`[${formatArray(dataShape ?? [])}]`} +
+
+ Chunk Shape + {`[${formatArray(chunkShape ?? [])}]`} +
+
+ {/* This should be the real original values */} + {/*
+
+ In memory: {formatBytes(currentSize)} +
+
+ On disk: {formatBytes(storedSize)} +
+
*/} + +
+ {DIMS.map((dim, i) => ( + + ))} +
+ {/* This will be the PLOT action. */} +
+ +
+ + ); +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index c3564fb8..f091c149 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -5,7 +5,7 @@ import { TbVariable } from "react-icons/tb"; 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"; @@ -315,15 +315,19 @@ 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-[400px]" > - {meta && ( - { + // close UI after applying selections + setOpenMetaPopover(false); + setOpenVariables(false); + // future: persist sels/axes to store + console.log('Applied slices', sels, axes); + }} /> )} @@ -335,12 +339,13 @@ const Variables = () => { {}
{meta && ( - { + setShowMeta(false); + setOpenVariables(false); + console.log('Applied slices', sels, axes); + }} /> )}