From 797a3c9b72d3c84572ca673b3812448d165139db Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Mon, 15 Jun 2026 23:02:29 +0200 Subject: [PATCH 01/12] wire-in component --- src/components/ui/DimConfig/DimConfigBar.tsx | 116 +++++ .../ui/DimConfig/DimConfigEntry.tsx | 131 +++++ src/components/ui/DimConfig/index.ts | 2 + src/components/ui/DimSlicer/DimSlicer.tsx | 455 ++++++++++++++++++ .../ui/DimSlicer/DimSlicerAxisToggle.tsx | 51 ++ .../ui/DimSlicer/DimSlicerModeToggle.tsx | 67 +++ .../ui/DimSlicer/DimSlicerNumericControl.tsx | 36 ++ .../DimSlicerNumericInputWithStepper.tsx | 78 +++ .../ui/DimSlicer/DimSlicerTimeControl.tsx | 65 +++ src/components/ui/DimSlicer/TimeCombobox.tsx | 88 ++++ src/components/ui/DimSlicer/index.ts | 2 + .../ui/MainPanel/MetaDimSelector.tsx | 131 +++++ src/components/ui/MainPanel/Variables.tsx | 26 +- 13 files changed, 1237 insertions(+), 11 deletions(-) create mode 100644 src/components/ui/DimConfig/DimConfigBar.tsx create mode 100644 src/components/ui/DimConfig/DimConfigEntry.tsx create mode 100644 src/components/ui/DimConfig/index.ts create mode 100644 src/components/ui/DimSlicer/DimSlicer.tsx create mode 100644 src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx create mode 100644 src/components/ui/DimSlicer/DimSlicerModeToggle.tsx create mode 100644 src/components/ui/DimSlicer/DimSlicerNumericControl.tsx create mode 100644 src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx create mode 100644 src/components/ui/DimSlicer/DimSlicerTimeControl.tsx create mode 100644 src/components/ui/DimSlicer/TimeCombobox.tsx create mode 100644 src/components/ui/DimSlicer/index.ts create mode 100644 src/components/ui/MainPanel/MetaDimSelector.tsx diff --git a/src/components/ui/DimConfig/DimConfigBar.tsx b/src/components/ui/DimConfig/DimConfigBar.tsx new file mode 100644 index 00000000..951cc2bd --- /dev/null +++ b/src/components/ui/DimConfig/DimConfigBar.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { useState } from 'react'; + +import { + Axis, + canUseSliceMode, + getAvailableSliceAxes, + SelectionMode, + SliceSelectionState, +} from '@/components/ui/DimSlicer'; +import { Button } from '@/components/ui/button'; + +import { DimConfigEntry } from './DimConfigEntry'; + +export interface DimConfigBarProps { + variableName: string; + dims: { name: string }[]; + sels: SliceSelectionState[]; + axes: Axis[]; + onModeChange: (index: number, mode: SelectionMode) => void; + onAxisChange: (index: number, axis: Axis) => void; +} + +const formatConfigToken = (selection: SliceSelectionState) => { + if (selection.mode === 'scalar') { + return selection.scalar || '0'; + } + + const start = selection.start || '0'; + const stop = selection.stop || ''; + + if (start === '0' && stop === '') { + return ':'; + } + + return `${start}:${stop}`; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-600', + z: 'text-blue-500', + c: 'text-yellow-600', +}; + +export function DimConfigBar({ + variableName, + dims, + sels, + axes, + onModeChange, + onAxisChange, +}: DimConfigBarProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+
+
+ {variableName} + [ + {sels.map((selection, index) => { + const tokenClass = + selection.mode === 'scalar' + ? 'text-slate-500' + : AXIS_COLOR[axes[index]]; + + return ( + + {formatConfigToken(selection)} + {index < sels.length - 1 ? ', ' : ''} + + ); + })} + ] +
+
+ Current slicing expression +
+
+ + +
+ + {expanded ? ( +
+ {dims.map((dim, i) => ( + onModeChange(i, mode)} + onAxisChange={axis => onAxisChange(i, axis)} + /> + ))} +
+ ) : null} +
+
+ ); +} diff --git a/src/components/ui/DimConfig/DimConfigEntry.tsx b/src/components/ui/DimConfig/DimConfigEntry.tsx new file mode 100644 index 00000000..3e10b46f --- /dev/null +++ b/src/components/ui/DimConfig/DimConfigEntry.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { + Axis, + SelectionMode, + SliceSelectionState, +} from '@/components/DimSlicer'; +import { Button } from '@/components/ui/button'; +import { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, +} from '@/components/ui/button-group'; + +import { cn } from '@/lib/utils'; + +const AXIS_OPTIONS: Axis[] = ['x', 'y', 'z', 'c']; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-600', + z: 'text-blue-500', + c: 'text-yellow-600', +}; + +const SELECTED_AXIS_BUTTON_CLASSES: Record = { + x: 'text-white bg-pink-500 border-pink-500 hover:bg-pink-600', + y: 'text-white bg-green-600 border-green-600 hover:bg-green-700', + z: 'text-white bg-blue-500 border-blue-500 hover:bg-blue-600', + c: 'text-white bg-yellow-600 border-yellow-600 hover:bg-yellow-700', +}; + +const formatSelection = (sel: SliceSelectionState) => + sel.mode === 'scalar' + ? `[${sel.scalar}]` + : `[${sel.start}:${sel.stop}]`; + +export interface DimConfigEntryProps { + dimName: string; + selection: SliceSelectionState; + axis: Axis; + sliceAllowed: boolean; + availableAxes: Axis[]; + onModeChange: (mode: SelectionMode) => void; + onAxisChange: (axis: Axis) => void; +} + +export function DimConfigEntry({ + dimName, + selection, + axis, + sliceAllowed, + availableAxes, + onModeChange, + onAxisChange, +}: DimConfigEntryProps) { + const isSlice = selection.mode === 'slice'; + const axisValue = availableAxes.includes(axis) + ? axis + : (availableAxes[0] ?? axis); + + return ( + + + {dimName} + + + + + {sliceAllowed ? ( + + ) : null} + + + + {isSlice ? ( + <> + + {AXIS_OPTIONS.map(a => { + const isDisabled = + a === 'c' || !availableAxes.includes(a); + const isSelected = axisValue === a; + + return ( + + ); + })} + + ) : null} + + + + + {formatSelection(selection)} + + + ); +} diff --git a/src/components/ui/DimConfig/index.ts b/src/components/ui/DimConfig/index.ts new file mode 100644 index 00000000..63ca55d7 --- /dev/null +++ b/src/components/ui/DimConfig/index.ts @@ -0,0 +1,2 @@ +export { DimConfigBar } from './DimConfigBar'; +export { DimConfigEntry } from './DimConfigEntry'; diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx new file mode 100644 index 00000000..0ef3034b --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -0,0 +1,455 @@ +'use client'; +import React from 'react'; +import { Slider } from '@/components/ui/slider'; + +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 const SLICE_AXES: Axis[] = ['x', 'y', 'z']; + +export function getUsedSliceAxes( + sels: SliceSelectionState[], + axes: Axis[], + excludeIndex?: number +): Set { + const used = new Set(); + sels.forEach((sel, i) => { + if (i === excludeIndex || sel.mode !== 'slice') return; + if (SLICE_AXES.includes(axes[i])) { + used.add(axes[i]); + } + }); + return used; +} + +export function getAvailableSliceAxes( + sels: SliceSelectionState[], + axes: Axis[], + dimIndex: number +): Axis[] { + const used = getUsedSliceAxes(sels, axes, dimIndex); + return SLICE_AXES.filter(a => !used.has(a)); +} + +export function canUseSliceMode( + sels: SliceSelectionState[], + axes: Axis[], + dimIndex: number +): boolean { + return getAvailableSliceAxes(sels, axes, dimIndex).length > 0; +} + +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) }; +} + +export function defaultAxisForIndex(index: number): Axis { + return SLICE_AXES[index] ?? 'x'; +} + +export function defaultSelectionForIndex( + index: number, + dimSize?: number +): SliceSelectionState { + const base = defaultSelection(dimSize); + if (index < SLICE_AXES.length) { + return { ...base, mode: 'slice' }; + } + return { ...base, mode: 'scalar' }; +} + +const MODE_ACCENT: Record = { + scalar: 'border-l-black-700', + slice: 'border-l-pink-400', +}; + +const AXIS_ACCENT: Record = { + x: 'border-l-pink-500', + y: 'border-l-green-600', + z: 'border-l-blue-500', + c: 'border-l-yellow-600', +}; + +function dimBadge(selection: SliceSelectionState, dimSize: number, step: number): string { + if (selection.mode === 'scalar') return selection.scalar || '0'; + const start = selection.start !== '' ? selection.start : '0'; + const stop = selection.stop !== '' ? selection.stop : String(dimSize); + const stepStr = step !== 1 ? `:${step}` : ''; + return `${start}–${stop}${stepStr}`; +} + + +export interface DimSlicerProps { + /** Dimension name, e.g. "time" or "dim_0" */ + dimName: string; + /** Size of this dimension */ + dimSize: number; + /** Current selection state */ + selection: SliceSelectionState; + /** Assigned axis for this dimension */ + axis: Axis; + /** Called whenever the selection changes */ + onChange: (next: SliceSelectionState) => void; + /** Step size for the slider (optional, defaults to 1) */ + step?: number; + /** 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, + axis, + onChange, + step = 1, + values, + formatValue, +}) => { + 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); + const showIndexLabel = !isDateDimension; + + const indexLabel = dimBadge(sel, effectiveDimSize, step); + + return ( +
+ {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' ? ( +
+
+ + {dimName} + {showIndexLabel ? ( + [{indexLabel}] + ) : null} + [{effectiveDimSize}] + +
+ + 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)} + /> +
+ ) : ( +
+
+ + {dimName} + {showIndexLabel ? ( + [{indexLabel}] + ) : null} + [{effectiveDimSize}] + +
+ +
+ 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} + /> + ) + ) : ( +
+ )} + +
+ + {dimName} + {showIndexLabel ? ( + [{indexLabel}] + ) : null} + [{effectiveDimSize}] + +
+ +
+ {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; diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx new file mode 100644 index 00000000..67b522d9 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -0,0 +1,51 @@ +'use client'; +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type Axis = 'x' | 'y' | 'z' | 'c'; + +const AXIS_OPTIONS: Axis[] = ['x', 'y', 'z', 'c']; + +const AXIS_BUTTON_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; +} + +export const DimSlicerAxisToggle: React.FC = ({ + axis, + onAxisChange, +}) => { + return ( + + {AXIS_OPTIONS.map(a => { + const isDisabled = a === 'c'; + const buttonClass = AXIS_BUTTON_CLASS[a]; + + return ( + + ); + })} + + ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx new file mode 100644 index 00000000..4677168c --- /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..af2cddcf --- /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="h-7 w-16 text-center appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-moz-appearance:textfield]" + 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..f1dc552f --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,88 @@ +'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 getIndexFromLabel = (label: string) => { + return values.findIndex((_, index) => formattedValue(index) === label) + } + + const handleValueChange = (value: unknown) => { + const label = typeof value === 'string' ? value : '' + if (label === '') { + setInputQuery('') + onIndexChange(includeEnd ? effectiveDimSize : 0) + return + } + const nextIndex = getIndexFromLabel(label) + if (nextIndex !== -1) { + setInputQuery('') + onIndexChange(nextIndex) + } + } + + const labels = values.map((_, i) => formattedValue(i)) + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + const filtered = normalizedInput === '' || normalizedInput === selectedQuery ? labels : labels.filter(l => l.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 => ( + + {label} + + ))} + + + + ) +} 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..94958524 --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useMemo, useState, useEffect } from 'react'; +import { DimConfigBar } from '@/components/ui/DimConfig'; +import DimSlicer, { + Axis, + canUseSliceMode, + defaultAxisForIndex, + defaultSelectionForIndex, + getAvailableSliceAxes, + SelectionMode, + SliceSelectionState, +} from '@/components/ui/DimSlicer'; +import { Button } from '@/components/ui/button'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +import { parseLoc } from '@/utils/HelperFuncs'; + +type Props = { + meta: any; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[]) => void; +}; + +export default function MetaDimSelector({ meta, onApply }: Props) { + const { dimArrays = [], dimNames = [], dimUnits = [] } = meta?.dimInfo ?? {}; + + const DIMS = useMemo( + () => + dimArrays.map((arr: any, idx: number) => { + const values = Array.isArray(arr) ? arr : []; + const size = values.length; + return { + name: dimNames?.[idx] ?? `dim${idx}`, + size, + values, + formatValue: (i: number) => parseLoc(values?.[i] ?? i, dimUnits?.[idx] ?? null), + }; + }), + [dimArrays, dimNames, dimUnits] + ); + + const [sels, setSels] = useState(() => + DIMS.map((d, i) => defaultSelectionForIndex(i, d.size)) + ); + const [axes, setAxes] = useState(() => DIMS.map((_, i) => defaultAxisForIndex(i))); + const [showSliders, setShowSliders] = useState(true); + + useEffect(() => { + // reset selections/axes when meta dims change + setSels(DIMS.map((d, i) => defaultSelectionForIndex(i, d.size))); + setAxes(DIMS.map((_, i) => defaultAxisForIndex(i))); + }, [DIMS.length]); + + const updateSelection = (i: number, next: SliceSelectionState) => { + setSels(prev => prev.map((s, idx) => (idx === i ? next : s))); + }; + + const updateAxis = (i: number, axis: Axis) => { + if (axis === 'c') return; + if (!getAvailableSliceAxes(sels, axes, i).includes(axis)) return; + setAxes(prev => prev.map((a, idx) => (idx === i ? axis : a))); + }; + + const updateMode = (i: number, mode: SelectionMode) => { + if (mode === 'slice' && !canUseSliceMode(sels, axes, i)) return; + + const nextSels = sels.map((s, idx) => (idx === i ? { ...s, mode } : s)); + setSels(nextSels); + + if (mode === 'slice') { + const available = getAvailableSliceAxes(nextSels, axes, i); + if (available.length > 0) { + const nextAxis = available.includes(axes[i]) ? axes[i] : available[0]; + setAxes(prev => prev.map((a, idx) => (idx === i ? nextAxis : a))); + } + } + }; + + return ( +
+ + +
+ Dimension Selector + Configure axis & slices from meta.dimInfo +
+ + +
+ + +
+
+ +
+ + {showSliders && ( +
+ {DIMS.map((dim, i) => ( +
+ updateSelection(i, next)} values={dim.values} formatValue={dim.formatValue} /> +
+ ))} +
+ )} + +
+ +
+
+
+
+
+ ); +} diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index c3564fb8..fefceeb6 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"; @@ -318,12 +318,15 @@ const Variables = () => { className="max-h-[80vh] overflow-y-auto w-[300px]" > {meta && ( - { + // close UI after applying selections + setOpenMetaPopover(false); + setOpenVariables(false); + // future: persist sels/axes to store + console.log('Applied slices', sels, axes); + }} /> )} @@ -335,12 +338,13 @@ const Variables = () => { {}
{meta && ( - { + setShowMeta(false); + setOpenVariables(false); + console.log('Applied slices', sels, axes); + }} /> )}
From c4193e6bd2ee20c29d7e452c48c1b2213dbce9df Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Mon, 15 Jun 2026 23:33:40 +0200 Subject: [PATCH 02/12] get values --- .../ui/MainPanel/MetaDimSelector.tsx | 74 +++++++++++++++---- src/components/zarr/ZarrLoaderLRU.ts | 18 +++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 94958524..4ec0f33a 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -12,6 +12,8 @@ import DimSlicer, { SliceSelectionState, } from '@/components/ui/DimSlicer'; import { Button } from '@/components/ui/button'; +import { useGlobalStore } from '@/GlobalStates/GlobalStore'; +import { useShallow } from 'zustand/shallow'; import { Card, @@ -23,41 +25,81 @@ import { import { parseLoc } from '@/utils/HelperFuncs'; +interface DimInfo { + dimArrays: ArrayLike[]; + dimNames: string[]; + dimUnits: (string | null)[]; +} + type Props = { - meta: any; - metadata?: Record; + meta: { + name?: string; + shape?: number[]; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; onApply?: (sels: SliceSelectionState[], axes: Axis[]) => void; }; export default function MetaDimSelector({ meta, onApply }: Props) { const { dimArrays = [], dimNames = [], dimUnits = [] } = meta?.dimInfo ?? {}; + const shape = meta?.shape ?? []; + const shapeLength = shape.length; + + const { setDimArrays, setDimNames, setDimUnits } = useGlobalStore( + useShallow((state) => ({ + setDimArrays: state.setDimArrays, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, + })) + ); + + // Map dimensions to axes based on shape and dimension position + const getAxisForDimIndex = (idx: number): Axis => { + if (shapeLength <= 1) return 'c'; // scalar or single dim + if (shapeLength === 2) { + return idx === 0 ? 'y' : 'x'; + } + if (shapeLength === 3) { + return idx === 0 ? 'z' : idx === 1 ? 'y' : 'x'; + } + // 4D: first is time/other, last 3 are z, y, x + if (shapeLength === 4) { + return idx === 0 ? 'c' : idx === 1 ? 'z' : idx === 2 ? 'y' : 'x'; + } + return 'c'; + }; const DIMS = useMemo( () => - dimArrays.map((arr: any, idx: number) => { - const values = Array.isArray(arr) ? arr : []; + dimArrays.map((arr: ArrayLike, idx: number) => { + const values = Array.from(arr ?? []); const size = values.length; return { name: dimNames?.[idx] ?? `dim${idx}`, size, values, - formatValue: (i: number) => parseLoc(values?.[i] ?? i, dimUnits?.[idx] ?? null), + formatValue: (i: number): string => { + const val = parseLoc(values?.[i] ?? i, (dimUnits?.[idx] ?? undefined) as string | undefined); + return String(val); + }, }; }), [dimArrays, dimNames, dimUnits] ); + console.log(dimArrays, dimNames, dimUnits) const [sels, setSels] = useState(() => - DIMS.map((d, i) => defaultSelectionForIndex(i, d.size)) + DIMS.map((d: typeof DIMS[0], i: number) => defaultSelectionForIndex(i, d.size)) ); - const [axes, setAxes] = useState(() => DIMS.map((_, i) => defaultAxisForIndex(i))); - const [showSliders, setShowSliders] = useState(true); + const [axes, setAxes] = useState(() => DIMS.map((_: typeof DIMS[0], i: number) => getAxisForDimIndex(i))); useEffect(() => { - // reset selections/axes when meta dims change - setSels(DIMS.map((d, i) => defaultSelectionForIndex(i, d.size))); - setAxes(DIMS.map((_, i) => defaultAxisForIndex(i))); - }, [DIMS.length]); + setSels(DIMS.map((d: typeof DIMS[0], i: number) => defaultSelectionForIndex(i, d.size))); + setAxes(DIMS.map((_: typeof DIMS[0], i: number) => getAxisForDimIndex(i))); + }, [DIMS, shapeLength]); + const [showSliders, setShowSliders] = useState(true); const updateSelection = (i: number, next: SliceSelectionState) => { setSels(prev => prev.map((s, idx) => (idx === i ? next : s))); @@ -84,6 +126,12 @@ export default function MetaDimSelector({ meta, onApply }: Props) { } }; + useEffect(() => { + setDimArrays(dimArrays); + setDimNames(dimNames); + setDimUnits(dimUnits); + }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); + return (
@@ -106,7 +154,7 @@ export default function MetaDimSelector({ meta, onApply }: Props) { {showSliders && (
- {DIMS.map((dim, i) => ( + {DIMS.map((dim: typeof DIMS[0], i: number) => (
updateSelection(i, next)} values={dim.values} formatValue={dim.formatValue} />
diff --git a/src/components/zarr/ZarrLoaderLRU.ts b/src/components/zarr/ZarrLoaderLRU.ts index c0e6eb12..766ab6ee 100644 --- a/src/components/zarr/ZarrLoaderLRU.ts +++ b/src/components/zarr/ZarrLoaderLRU.ts @@ -100,6 +100,24 @@ export async function GetZarrDims(variable: string) { const dimUnits: unknown[] = []; if (dimNames) { + // Check if all dimension data is cached + const allDimsCached = dimNames.every(dim => cache.has(`${initStore}_${dim}`)); + + if (!allDimsCached) { + // Dimension data not cached yet, fetch it + const group = await useZarrStore.getState().currentStore; + if (!group) throw new Error(`Failed to open store: ${initStore}`); + + if (group.store instanceof IcechunkStore) { + return getIcechunkDims( + group as zarr.Group, + variable, + initStore, + ); + } + return getFetchStoreDims(group, variable, initStore); + } + for (const dim of dimNames) { dimArrays.push(cache.get(`${initStore}_${dim}`) ?? [0]); dimUnits.push( From ef31cf23fff7e9db8ba999b2f416e42fe4ed0da1 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Mon, 15 Jun 2026 23:56:28 +0200 Subject: [PATCH 03/12] keys issues --- src/components/ui/DimSlicer/TimeCombobox.tsx | 42 ++++++++++++------- .../ui/MainPanel/MetaDimSelector.tsx | 2 +- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/components/ui/DimSlicer/TimeCombobox.tsx b/src/components/ui/DimSlicer/TimeCombobox.tsx index f1dc552f..a878913e 100644 --- a/src/components/ui/DimSlicer/TimeCombobox.tsx +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -1,5 +1,4 @@ 'use client' - import React, { useState } from 'react' import { Combobox, @@ -31,14 +30,14 @@ export default function TimeCombobox({ formattedValue, includeEnd = false, }: TimeComboboxProps) { - const selectedLabel = includeEnd && currentIndex === effectiveDimSize ? formattedValue(Math.max(effectiveDimSize - 1, 0)) : formattedValue(currentIndex) + const selectedLabel = + includeEnd && currentIndex === effectiveDimSize + ? formattedValue(Math.max(effectiveDimSize - 1, 0)) + : formattedValue(currentIndex) + const [inputQuery, setInputQuery] = useState('') const inputValue = inputQuery === '' ? selectedLabel : inputQuery - const getIndexFromLabel = (label: string) => { - return values.findIndex((_, index) => formattedValue(index) === label) - } - const handleValueChange = (value: unknown) => { const label = typeof value === 'string' ? value : '' if (label === '') { @@ -46,24 +45,35 @@ export default function TimeCombobox({ onIndexChange(includeEnd ? effectiveDimSize : 0) return } - const nextIndex = getIndexFromLabel(label) - if (nextIndex !== -1) { + const item = labeledValues.find(({ label: l }) => l === label) + if (item) { setInputQuery('') - onIndexChange(nextIndex) + onIndexChange(item.index) } } - const labels = values.map((_, i) => formattedValue(i)) + 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 ? labels : labels.filter(l => l.toLowerCase().includes(normalizedInput)) - const targetWidth = Math.min(Math.max(Math.max(selectedLabel.length, placeholder.length) + 2, 12), 20) + + 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))} + onInputValueChange={value => + setInputQuery(typeof value === 'string' ? value : String(value)) + } autoHighlight > {filtered.length === 0 ? No items found. : null} - {filtered.map(label => ( - + {filtered.map(({ label, index }) => ( + {label} ))} @@ -85,4 +95,4 @@ export default function TimeCombobox({ ) -} +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 4ec0f33a..e7865c07 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -155,7 +155,7 @@ export default function MetaDimSelector({ meta, onApply }: Props) { {showSliders && (
{DIMS.map((dim: typeof DIMS[0], i: number) => ( -
+
updateSelection(i, next)} values={dim.values} formatValue={dim.formatValue} />
))} From c7370057383531d727376679f71303b63aef3fb8 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Mon, 15 Jun 2026 23:57:24 +0200 Subject: [PATCH 04/12] no log --- src/components/ui/MainPanel/MetaDimSelector.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index e7865c07..a8041205 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -88,7 +88,6 @@ export default function MetaDimSelector({ meta, onApply }: Props) { }), [dimArrays, dimNames, dimUnits] ); - console.log(dimArrays, dimNames, dimUnits) const [sels, setSels] = useState(() => DIMS.map((d: typeof DIMS[0], i: number) => defaultSelectionForIndex(i, d.size)) From 5974a7d38d9e7bccdd4b0e669f18ae0de0204979 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Tue, 16 Jun 2026 00:03:31 +0200 Subject: [PATCH 05/12] config bar --- src/components/ui/DimConfig/DimConfigBar.tsx | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/components/ui/DimConfig/DimConfigBar.tsx b/src/components/ui/DimConfig/DimConfigBar.tsx index 951cc2bd..6ffb0f10 100644 --- a/src/components/ui/DimConfig/DimConfigBar.tsx +++ b/src/components/ui/DimConfig/DimConfigBar.tsx @@ -1,7 +1,5 @@ 'use client'; - import { useState } from 'react'; - import { Axis, canUseSliceMode, @@ -10,7 +8,6 @@ import { SliceSelectionState, } from '@/components/ui/DimSlicer'; import { Button } from '@/components/ui/button'; - import { DimConfigEntry } from './DimConfigEntry'; export interface DimConfigBarProps { @@ -26,14 +23,9 @@ const formatConfigToken = (selection: SliceSelectionState) => { if (selection.mode === 'scalar') { return selection.scalar || '0'; } - const start = selection.start || '0'; const stop = selection.stop || ''; - - if (start === '0' && stop === '') { - return ':'; - } - + if (start === '0' && stop === '') return ':'; return `${start}:${stop}`; }; @@ -63,13 +55,13 @@ export function DimConfigBar({ {variableName} [ {sels.map((selection, index) => { + if (!dims[index]) return null; const tokenClass = selection.mode === 'scalar' ? 'text-slate-500' : AXIS_COLOR[axes[index]]; - return ( - + {formatConfigToken(selection)} {index < sels.length - 1 ? ', ' : ''} @@ -77,9 +69,7 @@ export function DimConfigBar({ })} ]
-
- Current slicing expression -
+
Current slicing expression
{isSlice ? ( diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx index 4677168c..6cc569c9 100644 --- a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -38,7 +38,7 @@ export const DimSlicerModeToggle: React.FC = ({ mode, setExpanded(false); }} > - scalar + index
{showSliders && (
- {DIMS.map((dim: typeof DIMS[0], i: number) => ( -
- updateSelection(i, next)} values={dim.values} formatValue={dim.formatValue} /> + {DIMS.map((dim, i) => ( +
+ updateSelection(i, next)} + values={dim.values} + formatValue={dim.formatValue} + />
))}
)}
- +
From 3f1b6b9c9a50c8877a5f735165b6607f3f895f41 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Thu, 18 Jun 2026 18:31:33 +0200 Subject: [PATCH 09/12] all --- src/components/ui/DimSlicer/DimSlicer.tsx | 188 +++++++++--------- .../ui/DimSlicer/DimSlicerAxisToggle.tsx | 95 +++++---- .../ui/DimSlicer/DimSlicerModeToggle.tsx | 8 +- .../DimSlicerNumericInputWithStepper.tsx | 2 +- .../ui/MainPanel/MetaDimSelector.tsx | 163 ++++----------- src/components/ui/MainPanel/Variables.tsx | 2 +- 6 files changed, 190 insertions(+), 268 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index 0ef3034b..a0873160 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -1,7 +1,9 @@ 'use client'; -import React from 'react'; +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'; @@ -15,70 +17,21 @@ export interface SliceSelectionState { stop: string; } -export const SLICE_AXES: Axis[] = ['x', 'y', 'z']; - -export function getUsedSliceAxes( - sels: SliceSelectionState[], - axes: Axis[], - excludeIndex?: number -): Set { - const used = new Set(); - sels.forEach((sel, i) => { - if (i === excludeIndex || sel.mode !== 'slice') return; - if (SLICE_AXES.includes(axes[i])) { - used.add(axes[i]); - } - }); - return used; -} - -export function getAvailableSliceAxes( - sels: SliceSelectionState[], - axes: Axis[], - dimIndex: number -): Axis[] { - const used = getUsedSliceAxes(sels, axes, dimIndex); - return SLICE_AXES.filter(a => !used.has(a)); -} - -export function canUseSliceMode( - sels: SliceSelectionState[], - axes: Axis[], - dimIndex: number -): boolean { - return getAvailableSliceAxes(sels, axes, dimIndex).length > 0; -} - 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) }; } -export function defaultAxisForIndex(index: number): Axis { - return SLICE_AXES[index] ?? 'x'; -} - -export function defaultSelectionForIndex( - index: number, - dimSize?: number -): SliceSelectionState { - const base = defaultSelection(dimSize); - if (index < SLICE_AXES.length) { - return { ...base, mode: 'slice' }; - } - return { ...base, mode: 'scalar' }; -} - const MODE_ACCENT: Record = { - scalar: 'border-l-black-700', - slice: 'border-l-pink-400', + scalar: 'border-l-teal-700', + slice: 'border-l-[#644FF0]', }; -const AXIS_ACCENT: Record = { - x: 'border-l-pink-500', - y: 'border-l-green-600', - z: 'border-l-blue-500', - c: 'border-l-yellow-600', +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', }; function dimBadge(selection: SliceSelectionState, dimSize: number, step: number): string { @@ -97,12 +50,14 @@ export interface DimSlicerProps { dimSize: number; /** Current selection state */ selection: SliceSelectionState; - /** Assigned axis for this dimension */ - axis: Axis; /** 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) */ @@ -113,12 +68,14 @@ const DimSlicer: React.FC = ({ dimName, dimSize, selection, - axis, 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); @@ -190,11 +147,7 @@ const DimSlicer: React.FC = ({ const indexLabel = dimBadge(sel, effectiveDimSize, step); return ( -
+
{sel.mode === 'slice' && (
= ({ {isDateDimension ? ( sel.mode === 'scalar' ? (
-
+
{dimName} - {showIndexLabel ? ( - [{indexLabel}] - ) : null} + + {currentAxis} + {showIndexLabel ? `[${indexLabel}]` : ''} + [{effectiveDimSize}]
- 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({ mode })} /> + { + setCurrentAxis(axis); + onAxisChange?.(axis); + }} + /> + 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)} + /> +
) : (
-
+
{dimName} - {showIndexLabel ? ( - [{indexLabel}] - ) : null} + + {currentAxis} + {showIndexLabel ? `[${indexLabel}]` : ''} + [{effectiveDimSize}] +
+ updateSelection({ mode })} /> + { + setCurrentAxis(axis); + onAxisChange?.(axis); + }} + /> +
@@ -360,16 +335,35 @@ const DimSlicer: React.FC = ({ )}
- + {dimName} - {showIndexLabel ? ( - [{indexLabel}] - ) : null} + {sel.mode === 'slice' ? ( + + {currentAxis} + {showIndexLabel ? `[${indexLabel}]` : ''} + + ) : ( + showIndexLabel ? ( + [{indexLabel}] + ) : null + )} [{effectiveDimSize}]
+ updateSelection({ mode })} /> + + {sel.mode === 'slice' && ( + { + setCurrentAxis(axis); + onAxisChange?.(axis); + }} + /> + )} + {sel.mode === 'slice' ? ( showTimeControls ? ( = { - x: 'text-pink-500', - y: 'text-green-500', - z: 'text-blue-500', - c: 'text-yellow-500', -}; - interface DimSlicerAxisToggleProps { axis: Axis; onAxisChange?: (axis: Axis) => void; } -export const DimSlicerAxisToggle: React.FC = ({ - axis, - onAxisChange, -}) => { +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 ( - - {AXIS_OPTIONS.map(a => { - const isDisabled = a === 'c'; - const buttonClass = AXIS_BUTTON_CLASS[a]; - - 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 index 6cc569c9..4e6d952f 100644 --- a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -32,18 +32,18 @@ export const DimSlicerModeToggle: React.FC = ({ mode, -
- - {showSliders && ( -
- {DIMS.map((dim, i) => ( -
- updateSelection(i, next)} - values={dim.values} - formatValue={dim.formatValue} - /> -
- ))} -
- )} - -
- -
+ + {DIMS.map((dim, i) => ( + update(i, next)} + values={dim.values} + formatValue={dim.formatValue} + /> + ))} + +
+
); -} +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index fefceeb6..bc8a5e70 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -315,7 +315,7 @@ 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-[500px]" > {meta && ( Date: Thu, 18 Jun 2026 22:08:29 +0200 Subject: [PATCH 10/12] selections --- .../ui/MainPanel/MetaDimSelector.tsx | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 30347539..eb067ac9 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -1,7 +1,7 @@ "use client"; -import React, { useMemo, useState, useCallback } from 'react'; -import DimSlicer, { defaultSelection, SliceSelectionState } from '@/components/ui/DimSlicer'; +import React, { useMemo, useState } from 'react'; +import DimSlicer, { Axis, defaultSelection, SliceSelectionState } from '@/components/ui/DimSlicer'; import { Button } from '@/components/ui/button'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { useShallow } from 'zustand/shallow'; @@ -28,9 +28,33 @@ type Props = { [key: string]: unknown; }; metadata?: Record; - onApply?: (sels: SliceSelectionState[]) => void; + 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, onApply }: Props) { const rawDimArrays = meta?.dimInfo?.dimArrays ?? []; const rawDimNames = meta?.dimInfo?.dimNames ?? []; @@ -72,30 +96,73 @@ export default function MetaDimSelector({ meta, onApply }: Props) { [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)), + ); - // Reset selections when DIMS shape changes - const dimsKey = DIMS.map((d) => `${d.name}:${d.size}`).join('|'); 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 update = useCallback((i: number, next: SliceSelectionState) => - setSels((prev) => prev.map((s, idx) => (idx === i ? next : s))), - []); + const selUpdaters = useMemo( + () => DIMS.map((_, i) => (next: SliceSelectionState) => + setSels((prev) => { + const copy = prev.slice(); + copy[i] = next; + return copy; + }) + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [dimsKey], + ); + + const axisUpdaters = useMemo( + () => DIMS.map((_, i) => (axis: Axis) => + setAxes((prev) => { + const copy = prev.slice(); + copy[i] = axis; + return copy; + }) + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [dimsKey], + ); + + const summary = useMemo(() => selectionSummary(sels), [sels]); return (
- - Dimension Selector - - Configure axis & slices from meta.dimInfo + + {meta?.name ?? 'variable'} + + {/* e.g. temperature [ 0:364, 0:47, -90:89 ] */} + + {meta?.name ?? 'variable'} {summary} + + {/* e.g. (time, z, 0), (lon, x, 1), (lat, y, 2) */} +
+ {DIMS.map((dim, i) => ( + + ( + {dim.name} + ,{' '} + {axes[i]} + ,{' '} + {i} + ) + + ))} +
@@ -105,14 +172,16 @@ export default function MetaDimSelector({ meta, onApply }: Props) { dimName={dim.name} dimSize={dim.size} selection={sels[i]} - onChange={(next) => update(i, next)} + axis={axes[i]} + onChange={selUpdaters[i]} + onAxisChange={axisUpdaters[i]} values={dim.values} formatValue={dim.formatValue} /> ))}
- +
From 9a9ca6eea0a0062f86e6c698691ec20328eec5d0 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Thu, 18 Jun 2026 22:55:51 +0200 Subject: [PATCH 11/12] full original version back --- src/components/ui/DimConfig/DimConfigBar.tsx | 106 ------ .../ui/DimConfig/DimConfigEntry.tsx | 131 ------- src/components/ui/DimConfig/index.ts | 2 - src/components/ui/DimSlicer/DimSlicer.tsx | 333 +++++++----------- .../ui/DimSlicer/DimSlicerModeToggle.tsx | 4 +- .../ui/MainPanel/MetaDimSelector.tsx | 17 +- src/components/ui/MainPanel/Variables.tsx | 2 +- 7 files changed, 139 insertions(+), 456 deletions(-) delete mode 100644 src/components/ui/DimConfig/DimConfigBar.tsx delete mode 100644 src/components/ui/DimConfig/DimConfigEntry.tsx delete mode 100644 src/components/ui/DimConfig/index.ts diff --git a/src/components/ui/DimConfig/DimConfigBar.tsx b/src/components/ui/DimConfig/DimConfigBar.tsx deleted file mode 100644 index 6ffb0f10..00000000 --- a/src/components/ui/DimConfig/DimConfigBar.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; -import { useState } from 'react'; -import { - Axis, - canUseSliceMode, - getAvailableSliceAxes, - SelectionMode, - SliceSelectionState, -} from '@/components/ui/DimSlicer'; -import { Button } from '@/components/ui/button'; -import { DimConfigEntry } from './DimConfigEntry'; - -export interface DimConfigBarProps { - variableName: string; - dims: { name: string }[]; - sels: SliceSelectionState[]; - axes: Axis[]; - onModeChange: (index: number, mode: SelectionMode) => void; - onAxisChange: (index: number, axis: Axis) => void; -} - -const formatConfigToken = (selection: SliceSelectionState) => { - if (selection.mode === 'scalar') { - return selection.scalar || '0'; - } - const start = selection.start || '0'; - const stop = selection.stop || ''; - if (start === '0' && stop === '') return ':'; - return `${start}:${stop}`; -}; - -const AXIS_COLOR: Record = { - x: 'text-pink-500', - y: 'text-green-600', - z: 'text-blue-500', - c: 'text-yellow-600', -}; - -export function DimConfigBar({ - variableName, - dims, - sels, - axes, - onModeChange, - onAxisChange, -}: DimConfigBarProps) { - const [expanded, setExpanded] = useState(false); - - return ( -
-
-
-
-
- {variableName} - [ - {sels.map((selection, index) => { - if (!dims[index]) return null; - const tokenClass = - selection.mode === 'scalar' - ? 'text-slate-500' - : AXIS_COLOR[axes[index]]; - return ( - - {formatConfigToken(selection)} - {index < sels.length - 1 ? ', ' : ''} - - ); - })} - ] -
-
Current slicing expression
-
- - -
- - {expanded ? ( -
- {dims.map((dim, i) => ( - onModeChange(i, mode)} - onAxisChange={axis => onAxisChange(i, axis)} - /> - ))} -
- ) : null} -
-
- ); -} diff --git a/src/components/ui/DimConfig/DimConfigEntry.tsx b/src/components/ui/DimConfig/DimConfigEntry.tsx deleted file mode 100644 index 4cc2d5f4..00000000 --- a/src/components/ui/DimConfig/DimConfigEntry.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'use client'; - -import { - Axis, - SelectionMode, - SliceSelectionState, -} from '@/components/ui/DimSlicer'; -import { Button } from '@/components/ui/button'; -import { - ButtonGroup, - ButtonGroupSeparator, - ButtonGroupText, -} from '@/components/ui/button-group'; - -import { cn } from '@/lib/utils'; - -const AXIS_OPTIONS: Axis[] = ['x', 'y', 'z', 'c']; - -// const AXIS_COLOR: Record = { -// x: 'text-pink-500', -// y: 'text-green-600', -// z: 'text-blue-500', -// c: 'text-yellow-600', -// }; - -const SELECTED_AXIS_BUTTON_CLASSES: Record = { - x: 'text-white bg-pink-500 border-pink-500 hover:bg-pink-600', - y: 'text-white bg-green-600 border-green-600 hover:bg-green-700', - z: 'text-white bg-blue-500 border-blue-500 hover:bg-blue-600', - c: 'text-white bg-yellow-600 border-yellow-600 hover:bg-yellow-700', -}; - -const formatSelection = (sel: SliceSelectionState) => - sel.mode === 'scalar' - ? `[${sel.scalar}]` - : `[${sel.start}:${sel.stop}]`; - -export interface DimConfigEntryProps { - dimName: string; - selection: SliceSelectionState; - axis: Axis; - sliceAllowed: boolean; - availableAxes: Axis[]; - onModeChange: (mode: SelectionMode) => void; - onAxisChange: (axis: Axis) => void; -} - -export function DimConfigEntry({ - dimName, - selection, - axis, - sliceAllowed, - availableAxes, - onModeChange, - onAxisChange, -}: DimConfigEntryProps) { - const isSlice = selection.mode === 'slice'; - const axisValue = availableAxes.includes(axis) - ? axis - : (availableAxes[0] ?? axis); - - return ( - - - {dimName} - - - - - {sliceAllowed ? ( - - ) : null} - - - - {isSlice ? ( - <> - - {AXIS_OPTIONS.map(a => { - const isDisabled = - a === 'c' || !availableAxes.includes(a); - const isSelected = axisValue === a; - - return ( - - ); - })} - - ) : null} - - - - - {formatSelection(selection)} - - - ); -} diff --git a/src/components/ui/DimConfig/index.ts b/src/components/ui/DimConfig/index.ts deleted file mode 100644 index 63ca55d7..00000000 --- a/src/components/ui/DimConfig/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { DimConfigBar } from './DimConfigBar'; -export { DimConfigEntry } from './DimConfigEntry'; diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index a0873160..7b3b247a 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -27,22 +27,6 @@ const MODE_ACCENT: Record = { slice: 'border-l-[#644FF0]', }; -const AXIS_COLOR: Record = { - x: 'text-pink-500', - y: 'text-green-500', - z: 'text-blue-500', - c: 'text-yellow-500', -}; - -function dimBadge(selection: SliceSelectionState, dimSize: number, step: number): string { - if (selection.mode === 'scalar') return selection.scalar || '0'; - const start = selection.start !== '' ? selection.start : '0'; - const stop = selection.stop !== '' ? selection.stop : String(dimSize); - const stepStr = step !== 1 ? `:${step}` : ''; - return `${start}–${stop}${stepStr}`; -} - - export interface DimSlicerProps { /** Dimension name, e.g. "time" or "dim_0" */ dimName: string; @@ -142,12 +126,28 @@ const DimSlicer: React.FC = ({ const isTimeDimension = dimName.toLowerCase().includes('time'); const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date'); const showTimeControls = Boolean(values && isTimeDimension); - const showIndexLabel = !isDateDimension; - - const indexLabel = dimBadge(sel, effectiveDimSize, step); 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' && (
= ({ {isDateDimension ? ( sel.mode === 'scalar' ? ( -
-
- - {dimName} - - {currentAxis} - {showIndexLabel ? `[${indexLabel}]` : ''} - - [{effectiveDimSize}] - -
- -
- updateSelection({ mode })} /> - { - setCurrentAxis(axis); - onAxisChange?.(axis); - }} - /> - 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({ 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)} + />
) : ( -
-
- - {dimName} - - {currentAxis} - {showIndexLabel ? `[${indexLabel}]` : ''} - - [{effectiveDimSize}] - -
- updateSelection({ mode })} /> - { - setCurrentAxis(axis); - onAxisChange?.(axis); - }} - /> -
-
- -
+
+ 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({ start: String(newStart) })} - value={startValue} - placeholder={formattedValue(0)} - ariaLabel="Start value" + currentIndex={stopIndex} + onIndexChange={(newStop: number) => 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({ start: String(getIndexFromValue(parsed)) }); + updateSelection({ stop: String(getIndexFromValue(parsed)) }); } }} - onIncrement={() => changeStartBy(+1)} - onDecrement={() => changeStartBy(-1)} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd /> - -
- 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 - /> -
) @@ -334,111 +290,80 @@ const DimSlicer: React.FC = ({
)} -
- - {dimName} - {sel.mode === 'slice' ? ( - - {currentAxis} - {showIndexLabel ? `[${indexLabel}]` : ''} - - ) : ( - showIndexLabel ? ( - [{indexLabel}] - ) : null - )} - [{effectiveDimSize}] - -
- -
- updateSelection({ mode })} /> - - {sel.mode === 'slice' && ( - { - setCurrentAxis(axis); - onAxisChange?.(axis); - }} - /> - )} - - {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 ? ( + {sel.mode === 'slice' ? ( + showTimeControls ? ( updateSelection({ scalar: String(newScalar) })} - value={scalarValue} - placeholder={formattedValue(0)} - ariaLabel="Scalar value" + layout="row" + showInput={false} + currentIndex={stopIndex} + onIndexChange={(newStop: number) => 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({ scalar: String(getIndexFromValue(parsed)) }); + updateSelection({ stop: String(getIndexFromValue(parsed)) }); } }} - onIncrement={() => changeScalarBy(+1)} - onDecrement={() => changeScalarBy(-1)} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd /> ) : ( { const parsed = parseFloat(value); if (!Number.isNaN(parsed)) { - updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + updateSelection({ stop: String(getIndexFromValue(parsed)) }); } }} - onIncrement={() => changeScalarBy(+1)} - onDecrement={() => changeScalarBy(-1)} - ariaLabel="Scalar value" + 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} + /> + )}
)}
@@ -446,4 +371,4 @@ const DimSlicer: React.FC = ({ }; export { DimSlicer }; -export default DimSlicer; +export default DimSlicer; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx index 4e6d952f..30a15f4a 100644 --- a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -38,7 +38,7 @@ export const DimSlicerModeToggle: React.FC = ({ mode, setExpanded(false); }} > - scalar + index )}
diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index eb067ac9..0137c260 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -120,7 +120,6 @@ export default function MetaDimSelector({ meta, onApply }: Props) { return copy; }) ), - // eslint-disable-next-line react-hooks/exhaustive-deps [dimsKey], ); @@ -132,18 +131,16 @@ export default function MetaDimSelector({ meta, onApply }: Props) { return copy; }) ), - // eslint-disable-next-line react-hooks/exhaustive-deps [dimsKey], ); const summary = useMemo(() => selectionSummary(sels), [sels]); return ( -
- + //
+ - {meta?.name ?? 'variable'} - + {/* {meta?.name ?? 'variable'} */} {/* e.g. temperature [ 0:364, 0:47, -90:89 ] */} {meta?.name ?? 'variable'} {summary} @@ -165,7 +162,7 @@ export default function MetaDimSelector({ meta, onApply }: Props) {
- + {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 bc8a5e70..03cca3b5 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -315,7 +315,7 @@ const Variables = () => { data-meta-popover side="left" align="start" - className="max-h-[80vh] overflow-y-auto w-[500px]" + className="max-h-[80vh] overflow-y-auto w-[450px]" > {meta && ( Date: Fri, 19 Jun 2026 10:11:06 +0200 Subject: [PATCH 12/12] more ui --- src/app/globals.css | 8 + .../DimSlicerNumericInputWithStepper.tsx | 2 +- .../ui/MainPanel/MetaDimSelector.tsx | 143 ++++++++++++------ src/components/ui/MainPanel/Variables.tsx | 5 +- 4 files changed, 108 insertions(+), 50 deletions(-) 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/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx index 990c7b51..5adab2b9 100644 --- a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -47,7 +47,7 @@ export const DimSlicerNumericInputWithStepper: React.FC onValueChange(e.target.value)} onClick={() => setExpanded(false)} - className="h-7 text-xs w-16 text-center appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-moz-appearance:textfield]" + className="no-spinner h-7 text-xs w-16 text-center appearance-none" placeholder={placeholder} aria-label={ariaLabel} /> diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 0137c260..1981c587 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -2,6 +2,7 @@ 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'; @@ -13,6 +14,21 @@ import { 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[]; @@ -55,10 +71,12 @@ function selectionSummary(sels: SliceSelectionState[]): string { return `[ ${parts.join(', ')} ]`; } -export default function MetaDimSelector({ meta, onApply }: Props) { +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)), @@ -137,51 +155,82 @@ export default function MetaDimSelector({ meta, onApply }: Props) { const summary = useMemo(() => selectionSummary(sels), [sels]); return ( - //
- - - {/* {meta?.name ?? 'variable'} */} - {/* e.g. temperature [ 0:364, 0:47, -90:89 ] */} - - {meta?.name ?? 'variable'} {summary} - - - {/* e.g. (time, z, 0), (lon, x, 1), (lat, y, 2) */} -
- {DIMS.map((dim, i) => ( - - ( - {dim.name} - ,{' '} - {axes[i]} - ,{' '} - {i} - ) - - ))} -
-
- - - {DIMS.map((dim, i) => ( - - ))} - {/* This will be the PLOT action. */} -
- -
-
-
- //
+ <> + {`${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 03cca3b5..f091c149 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -315,11 +315,12 @@ const Variables = () => { data-meta-popover side="left" align="start" - className="max-h-[80vh] overflow-y-auto w-[450px]" + className="max-h-[80vh] overflow-y-auto w-[400px]" > - {meta && ( + {metadata && ( { // close UI after applying selections setOpenMetaPopover(false);