diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx new file mode 100644 index 00000000..119e9925 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -0,0 +1,408 @@ +'use client'; +import React, { useState, useCallback } from 'react'; +import { Slider } from '@/components/ui/slider'; +import { Trash2 } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +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 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) { + return clamp(Math.round(val / step) * step, 0, maxIndex); + } + 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 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 00000000..e4f1baf0 --- /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 00000000..14af38ec --- /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 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..3b9aa9a0 --- /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 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..b15c8f5c --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,100 @@ +'use client' +import React, { useState, useMemo } 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 = useMemo(() => { + return values.map((_, i) => ({ label: formattedValue(i), index: i })) + }, [values, formattedValue]) + + const filtered = useMemo(() => { + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + return normalizedInput === '' || normalizedInput === selectedQuery + ? labeledValues + : labeledValues.filter(({ label }) => label.toLowerCase().includes(normalizedInput)) + }, [inputValue, selectedLabel, labeledValues]) + + 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..d5a3454b --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,365 @@ +"use client"; + +import React, { useMemo, useState } 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 { Badge } from "@/components/ui"; +import { parseLoc } from '@/utils/HelperFuncs'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const MAX_ACTIVE_DIMS = 3; +const DEFAULT_AXES: Axis[] = ['z', 'y', 'x']; + +const formatArray = (value: string | number[]): string => { + if (typeof value === 'string') return value; + return Array.isArray(value) ? value.join(', ') : String(value); +}; + +interface DimInfo { + dimArrays: ArrayLike[]; + dimNames: string[]; + dimUnits: (string | null)[]; +} + +type Props = { + meta: { + name?: string; + shape?: number[]; + chunks?: number[]; + long_name?: string; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[], dimNames: string[]) => void; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +function axisForIndex(idx: number): Axis { + return DEFAULT_AXES[idx] ?? DEFAULT_AXES[DEFAULT_AXES.length - 1]; +} + +function selectionSummary( + availableDims: DimOption[], + activeRows: SlicerRow[], + collapsedSels: Record, +): string { + const parts = availableDims.map((dim) => { + const activeRow = activeRows.find((r) => r.dimName === dim.name); + const sel = activeRow ? activeRow.sel : collapsedSels[dim.name]; + if (!sel) return `${dim.name}=?`; + const range = + sel.mode === 'scalar' + ? sel.scalar || '0' + : `${sel.start !== '' ? sel.start : '0'}:${sel.stop !== '' ? sel.stop : ':'}`; + return `${dim.name}=${range}`; + }); + return `[ ${parts.join(', ')} ]`; +} + +interface SlicerRow { + id: number; + dimName: string; + sel: SliceSelectionState; + axis: Axis; +} + +let _nextId = 0; +const nextId = () => ++_nextId; + +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 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 => + Object.fromEntries(dims.map((d) => [d.name, { ...defaultSelection(d.size), mode: 'scalar' as const }])); + + /** Default to first MIN(3, availableDims.length) dims as active rows */ + const makeInitialRows = (dims: DimOption[]): SlicerRow[] => + dims.slice(0, MAX_ACTIVE_DIMS).map((d, i) => ({ + id: nextId(), + dimName: d.name, + sel: { ...defaultSelection(d.size), mode: 'slice' }, + axis: axisForIndex(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)); + } + + const firstUnusedDim = (currentRows: SlicerRow[]): string => { + const usedNames = new Set(currentRows.map((r) => r.dimName)); + return availableDims.find((d) => !usedNames.has(d.name))?.name ?? ''; + }; + + 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 newRow: SlicerRow = { + id: nextId(), + dimName, + sel: { ...defaultSelection(dim.size), mode: 'slice' }, + axis: axisForIndex(prev.length), + }; + return [...prev, newRow]; + }); + }; + + const removeLastRow = () => + setRows((prev) => prev.slice(0, -1)); + + 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 updateAxis = (id: number, axis: Axis) => + // setRows((prev) => prev.map((r) => (r.id === id ? { ...r, axis } : r))); + + const updateCollapsedSel = (dimName: string, sel: SliceSelectionState) => + setCollapsedSels((prev) => ({ ...prev, [dimName]: { ...sel, mode: 'scalar' } })); + + const summary = useMemo( + () => selectionSummary(availableDims, rows, collapsedSels), + [availableDims, rows, collapsedSels], + ); + + 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; + + return ( + <> + {`${meta.long_name} `} + + + Attributes + + + {renderAttributes(metadata, defaultAttributes)} + + +
+ +
+ {'selection'} {summary} +
+ +
+ {rows.map((row) => { + const originalIndex = availableDims.findIndex( + (d) => d.name === row.dimName + ); + + return ( + + ( + {row.dimName} + ,{' '} + {row.axis} + ,{' '} + + {originalIndex} + + ) + + ); + })} +
+ +
+
+ Data Shape + {`[${formatArray(dataShape ?? [])}]`} +
+
+ Chunk Shape + {`[${formatArray(chunkShape ?? [])}]`} +
+
+ +
+ + + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
+ + {/* Active slicers — locked to slice, z/y/x axes only, trash only on last */} +
+ {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)} + // onAxisChange={(axis) => updateAxis(row.id, axis)} + 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/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index c3564fb8..391605b1 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"; @@ -34,7 +34,7 @@ const Variables = () => { 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,9 +46,7 @@ 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 [meta, setMeta] = useState(null); @@ -110,10 +108,24 @@ const Variables = () => { // Handle variable selection const handleVariableSelect = (val: string, idx: number) => { setSelectedVar(val); - GetDimInfo(val).then(e => { - setDimNames(e.dimNames); - setDimArrays(e.dimArrays); - setDimUnits(e.dimUnits); + setMeta(null); + setMetadata(null); + + Promise.all([GetDimInfo(val), GetAttributes(val)]).then(([dimInfo, attr]) => { + 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); + }).catch((err) => { + console.error("Failed to fetch dimension info or attributes:", err); }); if (popoverSide === "left") { @@ -124,20 +136,10 @@ const Variables = () => { }; 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)); - } - } - }, [selectedVar, variables, zMeta, dimArrays, dimNames, dimUnits]); - - useEffect(()=>{ setSelectedVar(null); setMeta(null); setMetadata(null); - },[initStore, setMetadata]); + }, [initStore, setMetadata]); useEffect(() => { const handleResize = () => { @@ -315,15 +317,20 @@ 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); + }} /> )} @@ -332,15 +339,17 @@ const Variables = () => { {popoverSide === "top" && ( - {} + { }
{meta && ( - { + setShowMeta(false); + setOpenVariables(false); + console.log('Applied slices', sels, axes); + }} /> )}