From 797a3c9b72d3c84572ca673b3812448d165139db Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Mon, 15 Jun 2026 23:02:29 +0200 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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); From f85eba04a00efb7ea2c791101ccf7897b64abce3 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 19 Jun 2026 12:31:28 +0200 Subject: [PATCH 13/33] select --- src/components/ui/DimSlicer/DimSlicer.tsx | 110 ++++---- .../ui/MainPanel/MetaDimSelector.tsx | 263 ++++++++++-------- 2 files changed, 214 insertions(+), 159 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index 7b3b247a..e6cae91d 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -1,6 +1,14 @@ 'use client'; import React, { useState } 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'; @@ -27,29 +35,34 @@ const MODE_ACCENT: Record = { slice: 'border-l-[#644FF0]', }; +export interface DimOption { + name: string; + size: number; + values?: number[]; + formatValue?: (value: number) => string; +} + export interface DimSlicerProps { - /** Dimension name, e.g. "time" or "dim_0" */ + availableDims: DimOption[]; dimName: string; - /** Size of this dimension */ + onDimChange: (dimName: string) => void; + /** Called when the trash icon is clicked. If omitted, the icon is hidden. */ + onRemove?: () => void; dimSize: number; - /** Current selection state */ selection: SliceSelectionState; - /** Called whenever the selection changes */ onChange: (next: SliceSelectionState) => void; - /** Step size for the slider (optional, defaults to 1) */ step?: number; - /** Selected axis (optional, defaults to 'x') */ axis?: Axis; - /** Called when axis changes */ onAxisChange?: (axis: Axis) => void; - /** Array of actual values for this dimension (optional, if provided, dimSize should match values.length) */ values?: number[]; - /** Function to format values for display (optional) */ formatValue?: (value: number) => string; } const DimSlicer: React.FC = ({ + availableDims, dimName, + onDimChange, + onRemove, dimSize, selection, onChange, @@ -67,7 +80,6 @@ const DimSlicer: React.FC = ({ 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) { @@ -75,7 +87,6 @@ const DimSlicer: React.FC = ({ closestIndex = i; } } - return closestIndex; }; @@ -86,14 +97,14 @@ const DimSlicer: React.FC = ({ 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, Math.max(effectiveDimSize - 1, 0)); + val = clamp(val, 0, maxIndex); 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); @@ -128,11 +139,33 @@ const DimSlicer: React.FC = ({ const showTimeControls = Boolean(values && isTimeDimension); return ( -
+
+ + {onRemove && ( + + )} + + {/* Top row: dim select + mode toggle + axis toggle */} +
+ - {/* Top row: dim name + mode toggle + axis toggle (always shown above slider) */} -
- {dimName}
updateSelection({ mode })} /> {sel.mode === 'slice' && ( @@ -158,7 +191,7 @@ const DimSlicer: React.FC = ({ onValueChange={([newStart, newStop]) => updateSelection({ start: String(newStart), stop: String(newStop) }) } - className="w-full cursor-pointer" + className="w-full cursor-pointer [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" />
)} @@ -171,7 +204,7 @@ const DimSlicer: React.FC = ({ step={step} value={[scalarIndex]} onValueChange={([val]) => updateSelection({ scalar: String(val) })} - className="w-full [&_[data-slot=slider-range]]:bg-transparent cursor-pointer" + 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" />
)} @@ -192,9 +225,7 @@ const DimSlicer: React.FC = ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ scalar: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeScalarBy(+1)} onDecrement={() => changeScalarBy(-1)} @@ -215,14 +246,11 @@ const DimSlicer: React.FC = ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ start: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStartBy(+1)} onDecrement={() => changeStartBy(-1)} /> -
= ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ stop: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStopBy(+1)} onDecrement={() => changeStopBy(-1)} @@ -263,9 +289,7 @@ const DimSlicer: React.FC = ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ start: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStartBy(+1)} onDecrement={() => changeStartBy(-1)} @@ -276,9 +300,7 @@ const DimSlicer: React.FC = ({ placeholder={formattedValue(0)} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ start: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStartBy(+1)} onDecrement={() => changeStartBy(-1)} @@ -305,9 +327,7 @@ const DimSlicer: React.FC = ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ stop: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStopBy(+1)} onDecrement={() => changeStopBy(-1)} @@ -319,9 +339,7 @@ const DimSlicer: React.FC = ({ placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ stop: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeStopBy(+1)} onDecrement={() => changeStopBy(-1)} @@ -341,9 +359,7 @@ const DimSlicer: React.FC = ({ formattedValue={formattedValue} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ scalar: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeScalarBy(+1)} onDecrement={() => changeScalarBy(-1)} @@ -354,9 +370,7 @@ const DimSlicer: React.FC = ({ placeholder={formattedValue(0)} onValueChange={value => { const parsed = parseFloat(value); - if (!Number.isNaN(parsed)) { - updateSelection({ scalar: String(getIndexFromValue(parsed)) }); - } + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); }} onIncrement={() => changeScalarBy(+1)} onDecrement={() => changeScalarBy(-1)} diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 1981c587..89d8777e 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -1,34 +1,19 @@ "use client"; import React, { useMemo, useState } from 'react'; -import DimSlicer, { Axis, defaultSelection, SliceSelectionState } from '@/components/ui/DimSlicer'; -import Metadata, { defaultAttributes, renderAttributes } from "@/components/ui/MetaData" +import DimSlicer, { Axis, defaultSelection, DimOption, SliceSelectionState } from '@/components/ui/DimSlicer'; +import { defaultAttributes, renderAttributes } from "@/components/ui/MetaData"; import { Button } from '@/components/ui/button'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { useShallow } from 'zustand/shallow'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Badge } from "@/components/ui"; 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] -} + if (typeof value === 'string') return value; + return Array.isArray(value) ? value.join(', ') : String(value); +}; interface DimInfo { dimArrays: ArrayLike[]; @@ -40,11 +25,13 @@ type Props = { meta: { name?: string; shape?: number[]; + chunks?: number[]; + long_name?: string; dimInfo?: DimInfo; [key: string]: unknown; }; metadata?: Record; - onApply?: (sels: SliceSelectionState[], axes: Axis[]) => void; + onApply?: (sels: SliceSelectionState[], axes: Axis[], dimNames: string[]) => void; }; const AXIS_COLOR: Record = { @@ -61,22 +48,35 @@ function defaultAxisForIndex(idx: number, total: number): Axis { 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}`; +function selectionSummary(dimNames: string[], sels: SliceSelectionState[]): string { + const parts = sels.map((sel, i) => { + const name = dimNames[i] ?? `dim${i}`; + const range = + sel.mode === 'scalar' + ? sel.scalar || '0' + : `${sel.start !== '' ? sel.start : '0'}:${sel.stop !== '' ? sel.stop : ':'}`; + return `${name}=${range}`; }); return `[ ${parts.join(', ')} ]`; } +/** One active slicer row */ +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 dataShape = meta?.shape; + const chunkShape = meta?.chunks; const dimArrays: number[][] = useMemo( () => rawDimArrays.map((a) => Array.from(a)), @@ -102,89 +102,119 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { setDimUnits(dimUnits); }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); - const DIMS = useMemo( + /** All available dims derived from meta */ + const availableDims: DimOption[] = useMemo( () => dimArrays.map((values, idx) => ({ name: dimNames[idx] ?? `dim${idx}`, size: values.length, values, - formatValue: (i: number): string => - String(parseLoc(values[i] ?? i, dimUnits[idx] || undefined)), + formatValue: (v: number): string => + String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), })), [dimArrays, dimNames, dimUnits], ); - const dimsKey = DIMS.map((d) => `${d.name}:${d.size}`).join('|'); + const dimsKey = availableDims.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)), - ); + /** Pick the first dim name not already used by active rows, or fall back to first dim */ + const firstUnusedDim = (currentRows: SlicerRow[]): string => { + const usedNames = new Set(currentRows.map((r) => r.dimName)); + return availableDims.find((d) => !usedNames.has(d.name))?.name ?? availableDims[0]?.name ?? ''; + }; + const makeInitialRows = (dims: DimOption[]): SlicerRow[] => + dims.map((d, i) => ({ + id: nextId(), + dimName: d.name, + sel: defaultSelection(d.size), + axis: defaultAxisForIndex(i, dims.length), + })); + + const [rows, setRows] = useState(() => makeInitialRows(availableDims)); 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))); + setRows(makeInitialRows(availableDims)); } - const selUpdaters = useMemo( - () => DIMS.map((_, i) => (next: SliceSelectionState) => - setSels((prev) => { - const copy = prev.slice(); - copy[i] = next; - return copy; - }) - ), - [dimsKey], - ); + /** Add a new row defaulting to the first unused dim */ + const addRow = () => { + setRows((prev) => { + const dimName = firstUnusedDim(prev); + if (!dimName) return prev; // all dims already present and no duplicates allowed + const dim = availableDims.find((d) => d.name === dimName)!; + const newRow: SlicerRow = { + id: nextId(), + dimName, + sel: defaultSelection(dim.size), + axis: defaultAxisForIndex(prev.length, prev.length + 1), + }; + return [...prev, newRow]; + }); + }; + + const removeRow = (id: number) => + setRows((prev) => prev.filter((r) => r.id !== id)); + + 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), + }; + }), + ); + }; + + const updateSel = (id: number, sel: SliceSelectionState) => + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, sel } : r))); + + const updateAxis = (id: number, axis: Axis) => + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, axis } : r))); - const axisUpdaters = useMemo( - () => DIMS.map((_, i) => (axis: Axis) => - setAxes((prev) => { - const copy = prev.slice(); - copy[i] = axis; - return copy; - }) - ), - [dimsKey], + const summary = useMemo( + () => selectionSummary(rows.map((r) => r.dimName), rows.map((r) => r.sel)), + [rows], ); - const summary = useMemo(() => selectionSummary(sels), [sels]); + const canAddMore = rows.length < availableDims.length; return ( <> - {`${meta.long_name} `} - + {`${meta.long_name} `} + - Attributes + 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) => ( - + {rows.map((row, i) => ( + ( - {dim.name} + {row.dimName} ,{' '} - {axes[i]} + {row.axis} ,{' '} {i} ) @@ -192,45 +222,56 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { ))}
-
-
- Data Shape - {`[${formatArray(dataShape ?? [])}]`} -
-
- Chunk Shape - {`[${formatArray(chunkShape ?? [])}]`} -
-
- {/* This should be the real original values */} - {/*
-
- In memory: {formatBytes(currentSize)} +
+
+ Data Shape + {`[${formatArray(dataShape ?? [])}]`}
-
- On disk: {formatBytes(storedSize)} +
+ Chunk Shape + {`[${formatArray(chunkShape ?? [])}]`}
-
*/} - -
- {DIMS.map((dim, i) => ( - - ))} -
- {/* This will be the PLOT action. */} -
-
- + +
+ {rows.map((row) => { + const dim = availableDims.find((d) => d.name === row.dimName); + return ( + updateDimName(row.id, name)} + onRemove={() => removeRow(row.id)} + 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} + /> + ); + })} +
+
+ + + +
+ ); } \ No newline at end of file From 5e934f201b2138379274c3e0d255f76fd605a000 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 19 Jun 2026 12:48:28 +0200 Subject: [PATCH 14/33] collapsed --- .../ui/MainPanel/MetaDimSelector.tsx | 92 +++++++++++++++---- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 89d8777e..3067ed5c 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -9,6 +9,7 @@ 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 formatArray = (value: string | number[]): string => { if (typeof value === 'string') return value; @@ -48,19 +49,25 @@ function defaultAxisForIndex(idx: number, total: number): Axis { return (['c', 'z', 'y', 'x'] as Axis[])[idx] ?? 'c'; } -function selectionSummary(dimNames: string[], sels: SliceSelectionState[]): string { - const parts = sels.map((sel, i) => { - const name = dimNames[i] ?? `dim${i}`; +/** Summary preserving original dim order from availableDims */ +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 `${name}=${range}`; + return `${dim.name}=${range}`; }); return `[ ${parts.join(', ')} ]`; } -/** One active slicer row */ interface SlicerRow { id: number; dimName: string; @@ -102,7 +109,6 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { setDimUnits(dimUnits); }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); - /** All available dims derived from meta */ const availableDims: DimOption[] = useMemo( () => dimArrays.map((values, idx) => ({ @@ -117,7 +123,6 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { const dimsKey = availableDims.map((d) => `${d.name}:${d.size}`).join('|'); - /** Pick the first dim name not already used by active rows, or fall back to first dim */ const firstUnusedDim = (currentRows: SlicerRow[]): string => { const usedNames = new Set(currentRows.map((r) => r.dimName)); return availableDims.find((d) => !usedNames.has(d.name))?.name ?? availableDims[0]?.name ?? ''; @@ -131,19 +136,27 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { axis: defaultAxisForIndex(i, dims.length), })); + /** Collapsed dim scalar selections, keyed by dim name */ + const makeInitialCollapsedSels = (dims: DimOption[]): Record => + Object.fromEntries(dims.map((d) => [d.name, { ...defaultSelection(d.size), mode: 'scalar' as const }])); + 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)); } - /** Add a new row defaulting to the first unused dim */ const addRow = () => { setRows((prev) => { const dimName = firstUnusedDim(prev); - if (!dimName) return prev; // all dims already present and no duplicates allowed + if (!dimName) return prev; const dim = availableDims.find((d) => d.name === dimName)!; const newRow: SlicerRow = { id: nextId(), @@ -163,11 +176,7 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { prev.map((r) => { if (r.id !== id) return r; const dim = availableDims.find((d) => d.name === dimName); - return { - ...r, - dimName, - sel: defaultSelection(dim?.size), - }; + return { ...r, dimName, sel: defaultSelection(dim?.size) }; }), ); }; @@ -178,21 +187,29 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { 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, + // keep mode locked to scalar + [dimName]: { ...sel, mode: 'scalar' }, + })); + const summary = useMemo( - () => selectionSummary(rows.map((r) => r.dimName), rows.map((r) => r.sel)), - [rows], + () => selectionSummary(availableDims, rows, collapsedSels), + [availableDims, rows, collapsedSels], ); const canAddMore = rows.length < availableDims.length; + const activeDimNames = new Set(rows.map((r) => r.dimName)); + const collapsedDims = availableDims.filter((d) => !activeDimNames.has(d.name)); + return ( <> {`${meta.long_name} `} - - Attributes - + Attributes
+ {/* Active slicers */}
{rows.map((row) => { const dim = availableDims.find((d) => d.name === row.dimName); @@ -254,11 +272,45 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { ); })}
+ + {/* 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} + /> + ))} +
+ )} +
+ )} +
+
+ + + {/* Tooltip — shown on hover when disabled */} + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
- {/* Active slicers */} + {/* Active slicers — locked to slice mode */}
{rows.map((row) => { const dim = availableDims.find((d) => d.name === row.dimName); @@ -268,17 +265,18 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { onAxisChange={(axis) => updateAxis(row.id, axis)} values={dim?.values} formatValue={dim?.formatValue} + lockMode="slice" /> ); })}
- {/* Collapsed dimensions */} + {/* Collapsed dimensions — locked to scalar mode */} {collapsedDims.length > 0 && (
@@ -311,7 +310,7 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { - {/* Tooltip — shown on hover when disabled */} {addTooltip && (
From 0064651cad0c1fd77961b42f67d48401bc03c67b Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 19 Jun 2026 15:37:20 +0200 Subject: [PATCH 17/33] less options --- src/components/ui/DimSlicer/DimSlicer.tsx | 21 +++-- .../ui/DimSlicer/DimSlicerAxisToggle.tsx | 63 ++++++------- .../ui/MainPanel/MetaDimSelector.tsx | 93 ++++++++++--------- 3 files changed, 95 insertions(+), 82 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index 1d455977..4bd850d7 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -57,6 +57,8 @@ export interface DimSlicerProps { 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 = ({ @@ -73,8 +75,9 @@ const DimSlicer: React.FC = ({ values, formatValue, lockMode, + allowedAxes, }) => { - const [currentAxis, setCurrentAxis] = useState(propAxis); + // const [currentAxis, setCurrentAxis] = useState(propAxis); const effectiveDimSize = values ? values.length : dimSize; const rawSel = selection ?? defaultSelection(effectiveDimSize); const sel = lockMode ? { ...rawSel, mode: lockMode } : rawSel; @@ -121,7 +124,6 @@ const DimSlicer: React.FC = ({ }; const updateSelection = (patch: Partial) => { - // if mode is locked, never let a patch override it const next = { ...sel, ...patch }; if (lockMode) next.mode = lockMode; onChange(next); @@ -180,13 +182,14 @@ const DimSlicer: React.FC = ({ /> )} {sel.mode === 'slice' && ( - { - setCurrentAxis(axis); - onAxisChange?.(axis); - }} - /> + + {propAxis} + )}
diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx index 7d23b914..a58adac8 100644 --- a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -5,63 +5,64 @@ 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 }) => { +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); }, []); - const axisOptions: Axis[] = ['x', 'y', 'z', 'c']; - - const axisClass = axis === 'x' - ? 'text-pink-500' - : axis === 'y' - ? 'text-green-500' - : axis === 'z' - ? 'text-blue-500' - : 'text-yellow-500'; - return (
{expanded ? ( - {axisOptions.map(a => { - const buttonClass = a === 'x' ? 'text-pink-500' : a === 'y' ? 'text-green-500' : a === 'z' ? 'text-blue-500' : 'text-yellow-500'; - return ( - - ); - })} + {axisOptions.map(a => ( + + ))} ) : (
); -}; +}; \ No newline at end of file diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 4e579759..3b4e84d0 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -12,6 +12,7 @@ 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; @@ -44,11 +45,8 @@ const AXIS_COLOR: Record = { 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 axisForIndex(idx: number): Axis { + return DEFAULT_AXES[idx] ?? DEFAULT_AXES[DEFAULT_AXES.length - 1]; } function selectionSummary( @@ -127,7 +125,16 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { const makeInitialCollapsedSels = (dims: DimOption[]): Record => Object.fromEntries(dims.map((d) => [d.name, { ...defaultSelection(d.size), mode: 'scalar' as const }])); - const [rows, setRows] = useState([]); + /** 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), ); @@ -136,7 +143,7 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { if (dimsKey !== lastKey) { setLastKey(dimsKey); - setRows([]); + setRows(makeInitialRows(availableDims)); setCollapsedSels(makeInitialCollapsedSels(availableDims)); } @@ -155,14 +162,14 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { id: nextId(), dimName, sel: { ...defaultSelection(dim.size), mode: 'slice' }, - axis: defaultAxisForIndex(prev.length, prev.length + 1), + axis: axisForIndex(prev.length), }; return [...prev, newRow]; }); }; - const removeRow = (id: number) => - setRows((prev) => prev.filter((r) => r.id !== id)); + const removeLastRow = () => + setRows((prev) => prev.slice(0, -1)); const updateDimName = (id: number, dimName: string) => { setRows((prev) => @@ -177,8 +184,8 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { 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 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' } })); @@ -247,31 +254,56 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) {
- {/* Active slicers — locked to slice mode */} +
+ + + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
+ + {/* Active slicers — locked to slice, z/y/x axes only, trash only on last */}
- {rows.map((row) => { + {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={() => removeRow(row.id)} + 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)} + // onAxisChange={(axis) => updateAxis(row.id, axis)} values={dim?.values} formatValue={dim?.formatValue} lockMode="slice" + allowedAxes={['z', 'y', 'x']} /> ); })} -
+
- {/* Collapsed dimensions — locked to scalar mode */} + {/* Collapsed dimensions */} {collapsedDims.length > 0 && (
- - {addTooltip && ( -
-
- {addTooltip} -
-
- )} -
- +
From 5edc36d715578b036b9622c80020cd4c31d3ecac Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 19 Jun 2026 15:53:41 +0200 Subject: [PATCH 18/33] all feedback on ui redundacies --- .../ui/MainPanel/MetaDimSelector.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 3b4e84d0..3f74a99c 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -225,22 +225,30 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) {
-
+
{'selection'} {summary}
- {rows.map((row, i) => ( - - ( - {row.dimName} - ,{' '} - {row.axis} - ,{' '} - {i} - ) - - ))} + {rows.map((row) => { + const originalIndex = availableDims.findIndex( + (d) => d.name === row.dimName + ); + + return ( + + ( + {row.dimName} + ,{' '} + {row.axis} + ,{' '} + + {originalIndex} + + ) + + ); + })}
From a926c873a7e5a725ed6a6fc6b270fa494a411b21 Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 14:45:40 +0200 Subject: [PATCH 19/33] Stash --- src/GlobalStates/ZarrStore.ts | 2 + src/components/StoreInitializer.tsx | 36 ++++- src/components/ui/MainPanel/LocalNetCDF.tsx | 57 +++++--- src/components/ui/NavBar/Navbar.tsx | 2 + src/components/ui/NavBar/ParameterExport.tsx | 146 +++++++++++++++++++ 5 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 src/components/ui/NavBar/ParameterExport.tsx diff --git a/src/GlobalStates/ZarrStore.ts b/src/GlobalStates/ZarrStore.ts index 00755ee3..c7b90006 100644 --- a/src/GlobalStates/ZarrStore.ts +++ b/src/GlobalStates/ZarrStore.ts @@ -23,6 +23,7 @@ type ZarrState = { fetchOptions: FetchStoreOptions | null; abortController: AbortController | null; fetchKey: number; + ncBlobKey: string | undefined; // The key for the stored File blob for a local NC setZSlice: (zSlice: [number , number | null]) => void; setYSlice: (ySlice: [number , number | null]) => void; @@ -63,6 +64,7 @@ export const useZarrStore = create((set, get) => ({ fetchOptions: null, abortController: null, fetchKey: 0, + ncBlobKey: undefined, setZSlice: (zSlice) => set({ zSlice }), setYSlice: (ySlice) => set({ ySlice }), diff --git a/src/components/StoreInitializer.tsx b/src/components/StoreInitializer.tsx index 199308a4..c09cbcf5 100644 --- a/src/components/StoreInitializer.tsx +++ b/src/components/StoreInitializer.tsx @@ -3,8 +3,21 @@ import { useEffect, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { useGlobalStore } from "@/GlobalStates/GlobalStore"; import { useZarrStore } from '@/GlobalStates/ZarrStore'; +import { usePlotStore } from "@/GlobalStates/PlotStore"; import { useShallow } from 'zustand/shallow'; import { isRemoteStore } from '@/utils/isRemoteStore'; +import { loadNetCDF } from "@/utils/loadNetCDF"; +import { openDB } from "./ui/MainPanel/LocalNetCDF"; + +async function LoadNCBlob(blobKey:string){ + const db = await openDB(); + return new Promise((res, rej) => { + const tx = db.transaction('blobs', 'readonly'); + const req = tx.objectStore('blobs').get(blobKey); + req.onsuccess = () => res(req.result ?? null); + req.onerror = () => rej(req.error); + }); +} function StoreInitializerInner() { const searchParams = useSearchParams(); @@ -17,11 +30,32 @@ function StoreInitializerInner() { useEffect(() => { const store = searchParams.get("store"); + let data = searchParams.get("data") + if (data){ + const fullObj = JSON.parse(data); + console.log(fullObj.zarrState) + if (fullObj.zarrState.useNC){ + const blobKey = fullObj.zarrState.ncBlobKey + LoadNCBlob(blobKey).then(cache =>{ + //@ts-ignore cache is what we want + const file = cache.file + loadNetCDF(file, file.name).then(() => { + useZarrStore.setState(fullObj.zarrState); + useGlobalStore.setState(fullObj.globalState); + usePlotStore.setState(fullObj.plotState); + }) + }) + } else { + useZarrStore.setState(fullObj.zarrState) + useGlobalStore.setState(fullObj.globalState) + usePlotStore.setState(fullObj.plotState) + } + } if (!store) { setStoreFromURL(false); return; } - + const isNC = searchParams.get("format") === "nc"; setUseNC(isNC); setFetchNC(isNC); diff --git a/src/components/ui/MainPanel/LocalNetCDF.tsx b/src/components/ui/MainPanel/LocalNetCDF.tsx index fe16b3cc..8f386eef 100644 --- a/src/components/ui/MainPanel/LocalNetCDF.tsx +++ b/src/components/ui/MainPanel/LocalNetCDF.tsx @@ -10,33 +10,56 @@ import { AlertTitle, } from '@/components/ui/alert'; import { isMobile } from '../MobileUIHider'; +import { useZarrStore } from '@/GlobalStates/ZarrStore'; interface LocalNCType { setOpenVariables: (open: boolean) => void; } +const DB_NAME = 'browzarr-files'; +const STORE = 'blobs'; + +export function openDB(): Promise { // This will store File Blobs on disk for re-opening NetCDFs from searchParams. + return new Promise((res, rej) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); + }); +} + const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { const {setStatus } = useGlobalStore.getState() // const {ncModule} = useZarrStore.getState() const [ncError, setError] = useState(null); - const handleFileSelect = async (event: ChangeEvent) => { - setError(null); - const files = event.target.files; - if (!files || files.length === 0) { setStatus(null); return; } - const file = files[0]; - if (!NETCDF_EXT_REGEX.test(file.name)) { - setError('Please select a valid NetCDF (.nc, .netcdf, .nc3, .nc4) file.'); - return; - } - try { - await loadNetCDF(file, file.name); - setOpenVariables(true) - - } catch (e) { - setError(`Failed to load file: ${e instanceof Error ? e.message : String(e)}`); - } - }; + const handleFileSelect = async (event: ChangeEvent) => { + setError(null); + const files = event.target.files; + if (!files || files.length === 0) { setStatus(null); return; } + const file = files[0]; + if (!NETCDF_EXT_REGEX.test(file.name)) { + setError('Please select a valid NetCDF (.nc, .netcdf, .nc3, .nc4) file.'); + return; + } + try { + await loadNetCDF(file, file.name); + const db = await openDB(); + const key = `local_${file.name}` + new Promise((res, rej) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put({ file, key }, key); + tx.oncomplete = () => { + res(key); + useZarrStore.setState({ncBlobKey:key}) + }; + tx.onerror = () => rej(tx.error); + }); + setOpenVariables(true) + } catch (e) { + setError(`Failed to load file: ${e instanceof Error ? e.message : String(e)}`); + } + }; return (
diff --git a/src/components/ui/NavBar/Navbar.tsx b/src/components/ui/NavBar/Navbar.tsx index aa8ffb23..fe1efee4 100644 --- a/src/components/ui/NavBar/Navbar.tsx +++ b/src/components/ui/NavBar/Navbar.tsx @@ -19,6 +19,7 @@ import { import { cn } from "@/lib/utils"; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { usePlotStore } from '@/GlobalStates/PlotStore'; +import { ParameterExport } from "./ParameterExport"; import { Orthographic, Perspective } from "../Elements/Icons"; import PerformanceMode from "./PerformanceMode"; @@ -108,6 +109,7 @@ const Navbar = React.memo(function Navbar() { +
diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx new file mode 100644 index 00000000..693480e3 --- /dev/null +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -0,0 +1,146 @@ +import React, { useState } from 'react' +import { useGlobalStore } from '@/GlobalStates/GlobalStore' +import { usePlotStore } from '@/GlobalStates/PlotStore' +import { useZarrStore } from '@/GlobalStates/ZarrStore' +import { BiExport } from "react-icons/bi"; +import { FiCopy } from "react-icons/fi"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { Button } from '../button'; + +function pick(obj: Record, keys: string[]) { + return Object.fromEntries( + keys.map((k) => [k, obj[k]]) + ) +} +const globalValues = [ + 'initStore', + 'storeFromURL', + 'variable', +] + +const plotValues = [ + 'PlotType', + 'pointSize', + 'scalePoints', + 'scaleIntensity', + 'timeScale', + 'valueRange', + 'xRange', + 'yRange', + 'zRange', + 'showPoints', + 'linePointSize', + 'lineWidth', + 'lineColor', + 'pointColor', + 'useLineColor', + 'lineResolution', + 'cOffset', + 'cScale', + 'useFragOpt', + 'useCustomColor', + 'useCustomPointColor', + 'transparency', + 'nanTransparency', + 'nanColor', + 'showBorders', + 'borderColor', + 'lonExtent', + 'latExtent', + 'originalExtent', + 'lonResolution', + 'latResolution', + 'colorIdx', + 'vTransferRange', + 'vTransferScale', + 'sphereResolution', + 'displacement', + 'displaceSurface', + 'offsetNegatives', + 'zSlice', + 'ySlice', + 'xSlice', + 'interpPixels', + 'useOrtho', + 'rotateFlat', + 'fillValue', + 'coarsen', + 'kernel', + 'useBorderTexture', + 'maskValue', + 'borderWidth', + 'cameraPosition', + 'disablePointScale', + +] + +const zarrValues = [ + 'zSlice', + 'ySlice', + 'xSlice', + 'compress', + 'useNC', // This one is more static and so toggling switch doesn't break all other logic + 'fetchNC', + 'coarsen', + 'kernelSize', + 'kernelDepth', + 'icechunkOptions', + 'fetchOptions', + 'fetchKey', + 'ncBlobKey' +] + + +export const ParameterExport = () => { + const [copied, setCopied] = useState(false); + + function generateURL(){ + const fullObj = { + globalState: pick(useGlobalStore.getState(), globalValues), + plotState: pick(usePlotStore.getState(), plotValues), + zarrState: pick(useZarrStore.getState(), zarrValues), + } + const jString = JSON.stringify(fullObj, (_, v) => typeof v === 'bigint' ? v.toString() : v) + const params = `https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` + return params + } + + const copyToClipboard = async () => { + const url = generateURL() + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; + + return ( + + + + + +
+ + + +
+
+
+ ) +} + + From aac39d94f1da57d17411d85e04a190da001e64c2 Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 15:43:44 +0200 Subject: [PATCH 20/33] working with NetCDF --- src/GlobalStates/ZarrStore.ts | 4 +-- src/components/StoreInitializer.tsx | 21 +++---------- src/components/plots/AxisLines.tsx | 6 ++-- src/components/ui/MainPanel/LocalNetCDF.tsx | 26 +++------------ src/components/ui/MainPanel/LocalZarr.tsx | 5 ++- src/components/ui/NavBar/ParameterExport.tsx | 4 +-- src/utils/IndexDB.ts | 33 ++++++++++++++++++++ 7 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 src/utils/IndexDB.ts diff --git a/src/GlobalStates/ZarrStore.ts b/src/GlobalStates/ZarrStore.ts index c7b90006..423796df 100644 --- a/src/GlobalStates/ZarrStore.ts +++ b/src/GlobalStates/ZarrStore.ts @@ -23,7 +23,7 @@ type ZarrState = { fetchOptions: FetchStoreOptions | null; abortController: AbortController | null; fetchKey: number; - ncBlobKey: string | undefined; // The key for the stored File blob for a local NC + blobKey: string | undefined; // The key for the stored File blob for a local NC setZSlice: (zSlice: [number , number | null]) => void; setYSlice: (ySlice: [number , number | null]) => void; @@ -64,7 +64,7 @@ export const useZarrStore = create((set, get) => ({ fetchOptions: null, abortController: null, fetchKey: 0, - ncBlobKey: undefined, + blobKey: undefined, setZSlice: (zSlice) => set({ zSlice }), setYSlice: (ySlice) => set({ ySlice }), diff --git a/src/components/StoreInitializer.tsx b/src/components/StoreInitializer.tsx index c09cbcf5..f84f6a5d 100644 --- a/src/components/StoreInitializer.tsx +++ b/src/components/StoreInitializer.tsx @@ -7,17 +7,7 @@ import { usePlotStore } from "@/GlobalStates/PlotStore"; import { useShallow } from 'zustand/shallow'; import { isRemoteStore } from '@/utils/isRemoteStore'; import { loadNetCDF } from "@/utils/loadNetCDF"; -import { openDB } from "./ui/MainPanel/LocalNetCDF"; - -async function LoadNCBlob(blobKey:string){ - const db = await openDB(); - return new Promise((res, rej) => { - const tx = db.transaction('blobs', 'readonly'); - const req = tx.objectStore('blobs').get(blobKey); - req.onsuccess = () => res(req.result ?? null); - req.onerror = () => rej(req.error); - }); -} +import { loadFile } from "@/utils/IndexDB"; function StoreInitializerInner() { const searchParams = useSearchParams(); @@ -33,12 +23,11 @@ function StoreInitializerInner() { let data = searchParams.get("data") if (data){ const fullObj = JSON.parse(data); - console.log(fullObj.zarrState) - if (fullObj.zarrState.useNC){ - const blobKey = fullObj.zarrState.ncBlobKey - LoadNCBlob(blobKey).then(cache =>{ + if (fullObj.zarrState.useNC){ // If NC must load file beforehand + const blobKey = fullObj.zarrState.blobKey + loadFile(blobKey).then(cache =>{ //@ts-ignore cache is what we want - const file = cache.file + const file = cache.blob as File loadNetCDF(file, file.name).then(() => { useZarrStore.setState(fullObj.zarrState); useGlobalStore.setState(fullObj.globalState); diff --git a/src/components/plots/AxisLines.tsx b/src/components/plots/AxisLines.tsx index b21196bb..86d9a67d 100644 --- a/src/components/plots/AxisLines.tsx +++ b/src/components/plots/AxisLines.tsx @@ -114,9 +114,9 @@ const CubeAxis = ({flipX, flipY, flipDown}: {flipX: boolean, flipY: boolean, fli const zDimScale = zResolution/(zResolution-1) const zValDelta = 1/(zResolution-1) - const xTitleOffset = useMemo(() => (dimNames[shapeLength - 1].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const yTitleOffset = useMemo(() => (dimNames[shapeLength - 2].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); - const zTitleOffset = useMemo(() => (dimNames[shapeLength - 3].length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const xTitleOffset = useMemo(() => (dimNames[shapeLength - 1]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const yTitleOffset = useMemo(() => (dimNames[shapeLength - 2]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); + const zTitleOffset = useMemo(() => (dimNames[shapeLength - 3]?.length * AXIS_CONSTANTS.TITLE_FONT_SIZE_FACTOR / 2 + 0.1) * globalScale, [dimNames, globalScale]); return ( diff --git a/src/components/ui/MainPanel/LocalNetCDF.tsx b/src/components/ui/MainPanel/LocalNetCDF.tsx index 8f386eef..bce758f7 100644 --- a/src/components/ui/MainPanel/LocalNetCDF.tsx +++ b/src/components/ui/MainPanel/LocalNetCDF.tsx @@ -3,7 +3,7 @@ import React, {ChangeEvent, useState} from 'react' import { Input } from '../input' import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { loadNetCDF, NETCDF_EXT_REGEX } from '@/utils/loadNetCDF'; - +import { saveFile } from '@/utils/IndexDB'; import { Alert, AlertDescription, @@ -19,15 +19,6 @@ interface LocalNCType { const DB_NAME = 'browzarr-files'; const STORE = 'blobs'; -export function openDB(): Promise { // This will store File Blobs on disk for re-opening NetCDFs from searchParams. - return new Promise((res, rej) => { - const req = indexedDB.open(DB_NAME, 1); - req.onupgradeneeded = () => req.result.createObjectStore(STORE); - req.onsuccess = () => res(req.result); - req.onerror = () => rej(req.error); - }); -} - const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { const {setStatus } = useGlobalStore.getState() // const {ncModule} = useZarrStore.getState() @@ -44,17 +35,10 @@ const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { } try { await loadNetCDF(file, file.name); - const db = await openDB(); - const key = `local_${file.name}` - new Promise((res, rej) => { - const tx = db.transaction(STORE, 'readwrite'); - tx.objectStore(STORE).put({ file, key }, key); - tx.oncomplete = () => { - res(key); - useZarrStore.setState({ncBlobKey:key}) - }; - tx.onerror = () => rej(tx.error); - }); + const blobKey = `local_${file.name}` + await saveFile(file, file.name) + useZarrStore.setState({blobKey}) + console.log('Set Key?') setOpenVariables(true) } catch (e) { setError(`Failed to load file: ${e instanceof Error ? e.message : String(e)}`); diff --git a/src/components/ui/MainPanel/LocalZarr.tsx b/src/components/ui/MainPanel/LocalZarr.tsx index f5404046..50c37555 100644 --- a/src/components/ui/MainPanel/LocalZarr.tsx +++ b/src/components/ui/MainPanel/LocalZarr.tsx @@ -5,6 +5,7 @@ import { useZarrStore } from '@/GlobalStates/ZarrStore'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { Input } from '../input'; import ZarrParser from '@/components/zarr/ZarrParser'; +import { saveFile } from '@/utils/IndexDB'; interface LocalZarrType { setShowLocal: React.Dispatch>; @@ -52,10 +53,12 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) store = await ZarrParser(files, customStore) } const gs = await zarr.open(store, {kind: 'group'}); + const blobKey = `local_${baseDir}` + //saveFile(gs, blobKey) setCurrentStore(gs); setShowLocal(false); setOpenVariables(true); - setInitStore(`local_${baseDir}`) + setInitStore(blobKey) setStatus(null) useZarrStore.setState({ useNC: false}) } catch (error) { diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index 693480e3..a13c0ace 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -87,7 +87,7 @@ const zarrValues = [ 'icechunkOptions', 'fetchOptions', 'fetchKey', - 'ncBlobKey' + 'blobKey' ] @@ -101,7 +101,7 @@ export const ParameterExport = () => { zarrState: pick(useZarrStore.getState(), zarrValues), } const jString = JSON.stringify(fullObj, (_, v) => typeof v === 'bigint' ? v.toString() : v) - const params = `https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` + const params = `http://localhost:3000/?data=${encodeURIComponent(jString)}` //`https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` return params } diff --git a/src/utils/IndexDB.ts b/src/utils/IndexDB.ts new file mode 100644 index 00000000..d9e45268 --- /dev/null +++ b/src/utils/IndexDB.ts @@ -0,0 +1,33 @@ +const DB_NAME = 'browzarr-files'; +const STORE = 'blobs'; + + +export function openDB(): Promise { + return new Promise((res, rej) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => res(req.result); + req.onerror = () => rej(req.error); + }); +} + +export async function saveFile(blob: Blob, key: string): Promise { + const db = await openDB(); + return new Promise((res, rej) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put({ blob, key }, key); + tx.oncomplete = () => res(key); + tx.onerror = () => rej(tx.error); + }); +} + +export async function loadFile(key: string): Promise<{ blob: Blob; name: string } | null> { + const db = await openDB(); + console.log(key) + return new Promise((res, rej) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).get(key); + req.onsuccess = () => res(req.result ?? null); + req.onerror = () => rej(req.error); + }); +} \ No newline at end of file From 9eabd734dd073038a58b3d88f7ef334882e61a75 Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 16:17:20 +0200 Subject: [PATCH 21/33] LoadZarrFunction --- src/components/StoreInitializer.tsx | 38 ++++++------ src/components/ui/MainPanel/LocalZarr.tsx | 70 ++++++++++++----------- src/components/zarr/ZarrParser.ts | 2 + src/utils/IndexDB.ts | 4 +- 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/src/components/StoreInitializer.tsx b/src/components/StoreInitializer.tsx index f84f6a5d..cc55823a 100644 --- a/src/components/StoreInitializer.tsx +++ b/src/components/StoreInitializer.tsx @@ -8,6 +8,7 @@ import { useShallow } from 'zustand/shallow'; import { isRemoteStore } from '@/utils/isRemoteStore'; import { loadNetCDF } from "@/utils/loadNetCDF"; import { loadFile } from "@/utils/IndexDB"; +import { LoadLocalZarr } from "./ui/MainPanel/LocalZarr"; function StoreInitializerInner() { const searchParams = useSearchParams(); @@ -22,23 +23,28 @@ function StoreInitializerInner() { const store = searchParams.get("store"); let data = searchParams.get("data") if (data){ - const fullObj = JSON.parse(data); - if (fullObj.zarrState.useNC){ // If NC must load file beforehand - const blobKey = fullObj.zarrState.blobKey - loadFile(blobKey).then(cache =>{ - //@ts-ignore cache is what we want - const file = cache.blob as File - loadNetCDF(file, file.name).then(() => { - useZarrStore.setState(fullObj.zarrState); - useGlobalStore.setState(fullObj.globalState); - usePlotStore.setState(fullObj.plotState); + const fullObj = JSON.parse(data); + if (fullObj.zarrState.blobKey){ // If NC local must load file beforehand + const blobKey = fullObj.zarrState.blobKey + const isNC = fullObj.zarrState.useNC + loadFile(blobKey).then(cache =>{ + if (!isNC){ + LoadLocalZarr(cache?.blob as File[]) + } else { + //@ts-ignore cache is what we want + const file = cache.blob as File + loadNetCDF(file, file.name).then(() => { + useZarrStore.setState(fullObj.zarrState); + useGlobalStore.setState(fullObj.globalState); + usePlotStore.setState(fullObj.plotState); + }) + } }) - }) - } else { - useZarrStore.setState(fullObj.zarrState) - useGlobalStore.setState(fullObj.globalState) - usePlotStore.setState(fullObj.plotState) - } + } else { + useZarrStore.setState(fullObj.zarrState) + useGlobalStore.setState(fullObj.globalState) + usePlotStore.setState(fullObj.plotState) + } } if (!store) { setStoreFromURL(false); diff --git a/src/components/ui/MainPanel/LocalZarr.tsx b/src/components/ui/MainPanel/LocalZarr.tsx index 50c37555..de03a3f3 100644 --- a/src/components/ui/MainPanel/LocalZarr.tsx +++ b/src/components/ui/MainPanel/LocalZarr.tsx @@ -13,18 +13,9 @@ interface LocalZarrType { setInitStore: (store: string) => void; } -const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) => { - const setCurrentStore = useZarrStore(state => state.setCurrentStore) - const {setStatus} = useGlobalStore.getState() - const handleFileSelect = async (event: ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) { - setStatus(null) - return; - } - // Create a Map to hold the Zarr store data +export async function LoadLocalZarr(files:File[]){ + // Create a Map to hold the Zarr store data const fileMap = new Map(); - // The base directory name will be the first part of the relative path const baseDir = files[0].webkitRelativePath.split('/')[0]; @@ -36,7 +27,6 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) fileMap.set('/' + relativePath, file); // Zarrita looks for a leading slash before variables. Need to add it back } } - // Create a custom zarrita store from the Map const customStore: zarr.AsyncReadable = { async get(key: string): Promise { @@ -49,39 +39,53 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) // Open the Zarr store using the custom store let store = await zarr.withMaybeConsolidatedMetadata(customStore); if (!('contents' in store)){ + console.log("Trying to parse?") // Metadata is missing. We will need to parse variables here. store = await ZarrParser(files, customStore) } + console.log("Here?") const gs = await zarr.open(store, {kind: 'group'}); const blobKey = `local_${baseDir}` - //saveFile(gs, blobKey) - setCurrentStore(gs); - setShowLocal(false); - setOpenVariables(true); - setInitStore(blobKey) - setStatus(null) - useZarrStore.setState({ useNC: false}) + useGlobalStore.setState({initStore:blobKey}) + useZarrStore.setState({ useNC: false, blobKey, currentStore:gs}) } catch (error) { - setStatus(null) + if (error instanceof Error) { console.log(`Error opening Zarr store: ${error.message}`); } else { console.log('An unknown error occurred when opening the Zarr store.'); } } - }; - return ( -
- -
- ) +} + +const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) => { + const {setStatus} = useGlobalStore.getState() + const handleFileSelect = async (event: ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) { + setStatus(null) + return; + } + const baseDir = files[0].webkitRelativePath.split('/')[0]; + LoadLocalZarr(Array.from(files)).then(()=>{ + saveFile(Array.from(files), `local_${baseDir}`) + setShowLocal(false); + setOpenVariables(true); + setStatus(null) + }) + }; + return ( +
+ +
+ ) } export default LocalZarr diff --git a/src/components/zarr/ZarrParser.ts b/src/components/zarr/ZarrParser.ts index 74b47475..a8cdef0d 100644 --- a/src/components/zarr/ZarrParser.ts +++ b/src/components/zarr/ZarrParser.ts @@ -11,6 +11,7 @@ function is_v3(meta: any) { return "zarr_format" in meta && meta.zarr_format === 3; } async function ZarrParser(files: any, store: any){ + console.log(store) const fileCount = files.length; const vars = [] const metadata: { [key: string]: any } = {} @@ -26,6 +27,7 @@ async function ZarrParser(files: any, store: any){ } for (const variable of vars){ const decoded = await store.get(variable) + console.log(decoded) metadata[variable.slice(1)] = jsonDecodeObject(decoded) } const v2_meta = {metadata, zarr_consolidated_format: 1} diff --git a/src/utils/IndexDB.ts b/src/utils/IndexDB.ts index d9e45268..51ea99a7 100644 --- a/src/utils/IndexDB.ts +++ b/src/utils/IndexDB.ts @@ -11,7 +11,7 @@ export function openDB(): Promise { }); } -export async function saveFile(blob: Blob, key: string): Promise { +export async function saveFile(blob: any, key: string): Promise { const db = await openDB(); return new Promise((res, rej) => { const tx = db.transaction(STORE, 'readwrite'); @@ -21,7 +21,7 @@ export async function saveFile(blob: Blob, key: string): Promise { }); } -export async function loadFile(key: string): Promise<{ blob: Blob; name: string } | null> { +export async function loadFile(key: string): Promise<{ blob: any; name: string } | null> { const db = await openDB(); console.log(key) return new Promise((res, rej) => { From e4bf0fdd9ad4ac1340ff7d248cc025fc0d48b759 Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 17:23:33 +0200 Subject: [PATCH 22/33] Good enough --- src/GlobalStates/PlotStore.ts | 6 +++++ src/components/plots/Plot.tsx | 8 +++++++ src/components/ui/MainPanel/LocalContent.tsx | 1 - src/components/ui/MainPanel/LocalZarr.tsx | 24 +++++++++----------- src/components/ui/NavBar/ParameterExport.tsx | 24 ++++++++++++++------ src/utils/IndexDB.ts | 1 - 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/GlobalStates/PlotStore.ts b/src/GlobalStates/PlotStore.ts index 5f0e34f7..1b87962e 100644 --- a/src/GlobalStates/PlotStore.ts +++ b/src/GlobalStates/PlotStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import * as THREE from "three"; +import { cameraPosition } from "three/src/nodes/TSL.js"; type PlotState ={ plotType: string; @@ -64,6 +65,7 @@ type PlotState ={ maskValue: number; cameraPosition: THREE.Vector3; disablePointScale: boolean; + camera: THREE.Camera | undefined; setQuality: (quality: number) => void; setTimeScale: (timeScale : number) =>void; @@ -119,8 +121,10 @@ type PlotState ={ setUseOrtho: (useOrtho: boolean) => void; setFillValue: (fillValue: number | undefined) => void; setCameraPosition: (cameraPosition: THREE.Vector3) => void; + } + export const usePlotStore = create((set, get) => ({ plotType: "volume", pointSize: 5, @@ -185,6 +189,7 @@ export const usePlotStore = create((set, get) => ({ borderWidth: 0.05, cameraPosition: new THREE.Vector3(0, 0, 5), disablePointScale: false, + camera: undefined, setVTransferRange: (vTransferRange) => set({ vTransferRange }), setVTransferScale: (vTransferScale) => set({ vTransferScale }), @@ -242,4 +247,5 @@ export const usePlotStore = create((set, get) => ({ setUseOrtho: (useOrtho) => set({ useOrtho }), setFillValue: (fillValue) => set({ fillValue }), setCameraPosition: (cameraPosition) => set({ cameraPosition }), + })) \ No newline at end of file diff --git a/src/components/plots/Plot.tsx b/src/components/plots/Plot.tsx index d54e6ab3..b757a14a 100644 --- a/src/components/plots/Plot.tsx +++ b/src/components/plots/Plot.tsx @@ -39,6 +39,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ const hasMounted = useRef(false); const cameraRef = useRef(null) const {set, camera, size} = useThree() + // Reset Camera Position and Target useEffect(()=>{ if (!hasMounted.current) { @@ -81,6 +82,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[resetCamera, isFlat]) + // ---- Switch from Perspective to Orthographic ---- // useEffect(()=>{ if (hasMounted.current){ let newCamera; @@ -117,6 +119,7 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[useOrtho]) + // ---- Move camera to position ---- // useEffect(()=>{ const cam = cameraRef.current const controls = orbitRef.current @@ -136,6 +139,11 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ } },[cameraPosition]) + // ---- Camera Ref for state saves ---- // + useEffect(()=>{ + usePlotStore.setState({camera}) + },[camera]) + return ( {})} setOpenVariables={onOpenDescription} - setInitStore={setInitStore} /> )}
diff --git a/src/components/ui/MainPanel/LocalZarr.tsx b/src/components/ui/MainPanel/LocalZarr.tsx index de03a3f3..216ecbd9 100644 --- a/src/components/ui/MainPanel/LocalZarr.tsx +++ b/src/components/ui/MainPanel/LocalZarr.tsx @@ -10,7 +10,6 @@ import { saveFile } from '@/utils/IndexDB'; interface LocalZarrType { setShowLocal: React.Dispatch>; setOpenVariables: (open: boolean) => void; - setInitStore: (store: string) => void; } export async function LoadLocalZarr(files:File[]){ @@ -43,7 +42,6 @@ export async function LoadLocalZarr(files:File[]){ // Metadata is missing. We will need to parse variables here. store = await ZarrParser(files, customStore) } - console.log("Here?") const gs = await zarr.open(store, {kind: 'group'}); const blobKey = `local_${baseDir}` useGlobalStore.setState({initStore:blobKey}) @@ -58,7 +56,7 @@ export async function LoadLocalZarr(files:File[]){ } } -const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) => { +const LocalZarr = ({setShowLocal, setOpenVariables}:LocalZarrType) => { const {setStatus} = useGlobalStore.getState() const handleFileSelect = async (event: ChangeEvent) => { const files = event.target.files; @@ -68,21 +66,21 @@ const LocalZarr = ({setShowLocal, setOpenVariables, setInitStore}:LocalZarrType) } const baseDir = files[0].webkitRelativePath.split('/')[0]; LoadLocalZarr(Array.from(files)).then(()=>{ - saveFile(Array.from(files), `local_${baseDir}`) - setShowLocal(false); - setOpenVariables(true); - setStatus(null) + saveFile(Array.from(files), `local_${baseDir}`) + setShowLocal(false); + setOpenVariables(true); + setStatus(null) }) }; return (
) diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index a13c0ace..245c02e6 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -71,7 +71,6 @@ const plotValues = [ 'borderWidth', 'cameraPosition', 'disablePointScale', - ] const zarrValues = [ @@ -95,6 +94,8 @@ export const ParameterExport = () => { const [copied, setCopied] = useState(false); function generateURL(){ + const {camera} = usePlotStore.getState() + usePlotStore.setState({cameraPosition:camera?.position}) const fullObj = { globalState: pick(useGlobalStore.getState(), globalValues), plotState: pick(usePlotStore.getState(), plotValues), @@ -109,7 +110,7 @@ export const ParameterExport = () => { const url = generateURL() await navigator.clipboard.writeText(url); setCopied(true); - setTimeout(() => setCopied(false), 1500); + setTimeout(() => setCopied(false), 1500); //Use for a pop-up that fades away }; return ( @@ -119,24 +120,33 @@ export const ParameterExport = () => { variant="ghost" size="icon" className="cursor-pointer" - onClick={copyToClipboard} > - +
- - +
+ Copied! +
diff --git a/src/utils/IndexDB.ts b/src/utils/IndexDB.ts index 51ea99a7..e48ad0ca 100644 --- a/src/utils/IndexDB.ts +++ b/src/utils/IndexDB.ts @@ -23,7 +23,6 @@ export async function saveFile(blob: any, key: string): Promise { export async function loadFile(key: string): Promise<{ blob: any; name: string } | null> { const db = await openDB(); - console.log(key) return new Promise((res, rej) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).get(key); From a81cfe52c00633c2775e19b8caf3a93d5a45b919 Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 17:26:50 +0200 Subject: [PATCH 23/33] last comment --- src/components/ui/NavBar/ParameterExport.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index 245c02e6..49d6128f 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -95,7 +95,7 @@ export const ParameterExport = () => { function generateURL(){ const {camera} = usePlotStore.getState() - usePlotStore.setState({cameraPosition:camera?.position}) + usePlotStore.setState({cameraPosition:camera?.position}) // Set Camera position first to copy visual state const fullObj = { globalState: pick(useGlobalStore.getState(), globalValues), plotState: pick(usePlotStore.getState(), plotValues), From be8a461d3d834e00219ba20dbbacd4aa4146f1bb Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 17:28:00 +0200 Subject: [PATCH 24/33] locahost --> browzarr --- src/components/ui/NavBar/ParameterExport.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index 49d6128f..43e6a4f7 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -102,7 +102,7 @@ export const ParameterExport = () => { zarrState: pick(useZarrStore.getState(), zarrValues), } const jString = JSON.stringify(fullObj, (_, v) => typeof v === 'bigint' ? v.toString() : v) - const params = `http://localhost:3000/?data=${encodeURIComponent(jString)}` //`https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` + const params = `https://browzarr.io/latest/?data=${encodeURIComponent(jString)}` return params } From e35e87d96bd49ae35a3762b5c2733dc1dece885a Mon Sep 17 00:00:00 2001 From: Jeran Date: Fri, 19 Jun 2026 17:55:17 +0200 Subject: [PATCH 25/33] Gemini suggestions --- src/GlobalStates/PlotStore.ts | 1 - src/components/StoreInitializer.tsx | 46 +++++++++++--------- src/components/plots/Plot.tsx | 3 ++ src/components/ui/MainPanel/LocalNetCDF.tsx | 5 +-- src/components/ui/NavBar/ParameterExport.tsx | 2 +- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/GlobalStates/PlotStore.ts b/src/GlobalStates/PlotStore.ts index 1b87962e..39219a62 100644 --- a/src/GlobalStates/PlotStore.ts +++ b/src/GlobalStates/PlotStore.ts @@ -1,6 +1,5 @@ import { create } from "zustand"; import * as THREE from "three"; -import { cameraPosition } from "three/src/nodes/TSL.js"; type PlotState ={ plotType: string; diff --git a/src/components/StoreInitializer.tsx b/src/components/StoreInitializer.tsx index cc55823a..3cf178fb 100644 --- a/src/components/StoreInitializer.tsx +++ b/src/components/StoreInitializer.tsx @@ -23,27 +23,31 @@ function StoreInitializerInner() { const store = searchParams.get("store"); let data = searchParams.get("data") if (data){ - const fullObj = JSON.parse(data); - if (fullObj.zarrState.blobKey){ // If NC local must load file beforehand - const blobKey = fullObj.zarrState.blobKey - const isNC = fullObj.zarrState.useNC - loadFile(blobKey).then(cache =>{ - if (!isNC){ - LoadLocalZarr(cache?.blob as File[]) - } else { - //@ts-ignore cache is what we want - const file = cache.blob as File - loadNetCDF(file, file.name).then(() => { - useZarrStore.setState(fullObj.zarrState); - useGlobalStore.setState(fullObj.globalState); - usePlotStore.setState(fullObj.plotState); - }) - } - }) - } else { - useZarrStore.setState(fullObj.zarrState) - useGlobalStore.setState(fullObj.globalState) - usePlotStore.setState(fullObj.plotState) + try{ + const fullObj = JSON.parse(data); + if (fullObj.zarrState.blobKey){ // If NC local must load file beforehand + const blobKey = fullObj.zarrState.blobKey + const isNC = fullObj.zarrState.useNC + loadFile(blobKey).then(cache =>{ + if (!isNC){ + LoadLocalZarr(cache?.blob as File[]) + } else { + //@ts-ignore cache is what we want + const file = cache.blob as File + loadNetCDF(file, file.name).then(() => { + useZarrStore.setState(fullObj.zarrState); + useGlobalStore.setState(fullObj.globalState); + usePlotStore.setState(fullObj.plotState); + }) + } + }) + } else { + useZarrStore.setState(fullObj.zarrState) + useGlobalStore.setState(fullObj.globalState) + usePlotStore.setState(fullObj.plotState) + } + } catch { + console.error('Something Failed :/') } } if (!store) { diff --git a/src/components/plots/Plot.tsx b/src/components/plots/Plot.tsx index b757a14a..344d8ef9 100644 --- a/src/components/plots/Plot.tsx +++ b/src/components/plots/Plot.tsx @@ -142,6 +142,9 @@ const Orbiter = ({isFlat} : {isFlat : boolean}) =>{ // ---- Camera Ref for state saves ---- // useEffect(()=>{ usePlotStore.setState({camera}) + return () => { + usePlotStore.setState({camera: undefined}) + } },[camera]) return ( diff --git a/src/components/ui/MainPanel/LocalNetCDF.tsx b/src/components/ui/MainPanel/LocalNetCDF.tsx index bce758f7..fa9ee7d9 100644 --- a/src/components/ui/MainPanel/LocalNetCDF.tsx +++ b/src/components/ui/MainPanel/LocalNetCDF.tsx @@ -16,9 +16,6 @@ interface LocalNCType { setOpenVariables: (open: boolean) => void; } -const DB_NAME = 'browzarr-files'; -const STORE = 'blobs'; - const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { const {setStatus } = useGlobalStore.getState() // const {ncModule} = useZarrStore.getState() @@ -36,7 +33,7 @@ const LocalNetCDF = ({ setOpenVariables}:LocalNCType) => { try { await loadNetCDF(file, file.name); const blobKey = `local_${file.name}` - await saveFile(file, file.name) + await saveFile(file, blobKey) useZarrStore.setState({blobKey}) console.log('Set Key?') setOpenVariables(true) diff --git a/src/components/ui/NavBar/ParameterExport.tsx b/src/components/ui/NavBar/ParameterExport.tsx index 43e6a4f7..446206fc 100644 --- a/src/components/ui/NavBar/ParameterExport.tsx +++ b/src/components/ui/NavBar/ParameterExport.tsx @@ -19,7 +19,7 @@ const globalValues = [ ] const plotValues = [ - 'PlotType', + 'plotType', 'pointSize', 'scalePoints', 'scaleIntensity', From 623942153996ff0ca448868f15a36ca78556f8e6 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Sat, 20 Jun 2026 14:26:44 +0200 Subject: [PATCH 26/33] include icechunk in the main store menu (#668) * new zarr links * catalogs * mv assets * wrap --- src/assets/catalogs/IcechunkCatalog.ts | 128 ++++++++++++++++++ src/assets/catalogs/ZarrCatalog.ts | 92 +++++++++++++ src/assets/{ => imgs}/SeasFire-logo.png | Bin src/assets/{ => imgs}/line-graph.svg | 0 src/assets/{ => imgs}/logo.png | Bin src/assets/{ => imgs}/logoS.png | Bin src/assets/{ => imgs}/logo_bgc.png | Bin src/assets/{ => imgs}/logo_bgc_mpi.png | Bin src/assets/{ => imgs}/logo_mpi.png | Bin src/assets/{ => imgs}/logocut.png | Bin src/assets/{ => imgs}/seasfire_logo.png | Bin src/assets/{ => imgs}/settings.svg | 0 src/assets/index.ts | 18 ++- .../ui/MainPanel/CuratedDatasets.tsx | 5 +- src/components/ui/MainPanel/Dataset.tsx | 41 ++++-- .../ui/MainPanel/DatasetCollection.ts | 26 ---- .../ui/MainPanel/RemoteIcechunk.tsx | 2 +- src/components/ui/Widgets/Switcher.tsx | 4 +- 18 files changed, 267 insertions(+), 49 deletions(-) create mode 100644 src/assets/catalogs/IcechunkCatalog.ts create mode 100644 src/assets/catalogs/ZarrCatalog.ts rename src/assets/{ => imgs}/SeasFire-logo.png (100%) rename src/assets/{ => imgs}/line-graph.svg (100%) rename src/assets/{ => imgs}/logo.png (100%) rename src/assets/{ => imgs}/logoS.png (100%) rename src/assets/{ => imgs}/logo_bgc.png (100%) rename src/assets/{ => imgs}/logo_bgc_mpi.png (100%) rename src/assets/{ => imgs}/logo_mpi.png (100%) rename src/assets/{ => imgs}/logocut.png (100%) rename src/assets/{ => imgs}/seasfire_logo.png (100%) rename src/assets/{ => imgs}/settings.svg (100%) delete mode 100644 src/components/ui/MainPanel/DatasetCollection.ts diff --git a/src/assets/catalogs/IcechunkCatalog.ts b/src/assets/catalogs/IcechunkCatalog.ts new file mode 100644 index 00000000..5ba38e62 --- /dev/null +++ b/src/assets/catalogs/IcechunkCatalog.ts @@ -0,0 +1,128 @@ +export const ICECHUNK_CATALOG = [ + { + key: 'chirps-daily', + label: 'CHIRPS Daily Precipitation', + subtitle: '', + store: 'https://data.source.coop/e4drr-project/observations/chirps_daily_icechunk', + }, + { + key: 'earthmover-era5-surface', + label: 'Earthmover ERA5 Surface Reanalysis', + subtitle: 'Multi-Variable', + store: 'https://earthmover-icechunk-era5.s3.us-east-1.amazonaws.com/era5_surface_aws', + }, + { + key: 'noaa-gfs-forecast', + label: 'NOAA GFS', + subtitle: 'Global Weather Forecast', + store: 'https://dynamical-noaa-gfs.s3.us-west-2.amazonaws.com/noaa-gfs-forecast/v0.2.7.icechunk/', + }, + { + key: 'dwd-icon-eu-forecast', + label: 'DWD ICON-EU', + subtitle: 'Numerical Weather Forecast', + store: 'https://dynamical-dwd-icon-eu.s3.us-west-2.amazonaws.com/dwd-icon-eu-forecast-5-day/v0.2.0.icechunk/', + }, + { + key: 'ecmwf-aifs-single-forecast', + label: 'ECMWF AIFS Single Forecast', + subtitle: 'Artificial Intelligence Forecasting System', + store: 'https://dynamical-ecmwf-aifs-single.s3.us-west-2.amazonaws.com/ecmwf-aifs-single-forecast/v0.1.0.icechunk/', + }, + { + key: 'metoffice-global-wave', + label: 'UK Met Office Global Wave Forecast', + subtitle: '', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_wave.icechunk', + }, + { + key: 'metoffice-global-deterministic-6h', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '6-hourly 24hr', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km_6hourly_24hr.icechunk', + }, + { + key: 'nasa-geos-15min', + label: 'NASA GEOS Atmospheric Reanalysis', + subtitle: '15-minute', + store: 'https://data.source.coop/bkr/geos/geos_15min.icechunk', + }, + { + key: 'metoffice-global-deterministic-11h', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '11-hour', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km_11hour.icechunk', + }, + { + key: 'metoffice-global-deterministic', + label: 'UK Met Office Global Deterministic Atmosphere Forecast', + subtitle: '', + store: 'https://data.source.coop/bkr/metoffice/metoffice_global_deterministic_10km.icechunk', + }, + { + key: 'noaa-hrrr-analysis', + label: 'NOAA HRRR', + subtitle: 'High-Resolution Rapid Refresh Analysis', + store: 'https://dynamical-noaa-hrrr.s3.us-west-2.amazonaws.com/noaa-hrrr-analysis/v0.2.0.icechunk/', + }, + { + key: 'noaa-mrms-conus-hourly', + label: 'NOAA MRMS CONUS', + subtitle: 'Hourly Radar-Based Precipitation Analysis', + store: 'https://dynamical-noaa-mrms.s3.amazonaws.com/noaa-mrms-conus-analysis-hourly/v0.3.0.icechunk/', + }, + { + key: 'ecmwf-aifs-ens-forecast', + label: 'ECMWF AIFS Ensemble Forecast', + subtitle: '', + store: 'https://dynamical-ecmwf-aifs-ens.s3.us-west-2.amazonaws.com/ecmwf-aifs-ens-forecast/v0.1.0.icechunk/', + }, + { + key: 'ecmwf-ifs-ens-15day', + label: 'ECMWF IFS Ensemble 15-day Weather Forecast', + subtitle: '0.25°', + store: 'https://dynamical-ecmwf-ifs-ens.s3.us-west-2.amazonaws.com/ecmwf-ifs-ens-forecast-15-day-0-25-degree/v0.1.0.icechunk/', + }, + { + key: 'noaa-gefs-35day-forecast', + label: 'NOAA GEFS 35-day Ensemble Forecast', + subtitle: '', + store: 'https://dynamical-noaa-gefs.s3.us-west-2.amazonaws.com/noaa-gefs-forecast-35-day/v0.2.0.icechunk/', + }, + { + key: 'silam-aerosol', + label: 'SILAM Global Dust Model Forecasts', + subtitle: 'Aerosol', + store: 'https://data.source.coop/bkr/silam-dust/silam_aerosol.icechunk', + }, + { + key: 'cams-aerosol-analysis', + label: 'CAMS Aerosol Analysis', + subtitle: 'Copernicus CAMS Operational Archive', + store: 'https://data.source.coop/bkr/cams/cams.icechunk', + }, + { + key: 'cams-aerosol-forecast', + label: 'CAMS Aerosol Forecast', + subtitle: 'Copernicus CAMS Operational Archive', + store: 'https://data.source.coop/bkr/cams/cams_analysis_and_forecast.icechunk', + }, + { + key: 'nasa-rasi', + label: 'NASA RASI', + subtitle: 'Risk Analysis and Solutions Innovators', + store: 'https://nasa-waterinsight.s3.us-west-2.amazonaws.com/virtual-zarr-store/icechunk/RASI/HISTORICAL/', + }, + { + key: 'alaska-hrrr', + label: 'Alaska HRRR', + subtitle: '', + store: 'https://data.source.coop/bkr/dmi/alaska_hrrr.icechunk', + }, + { + key: 'ndvi-test', + label: 'NDVI', + subtitle: 'Normalized Difference Vegetation Index', + store: 'https://data.source.coop/eeholmes/chlaz/icechunk', + }, +]; \ No newline at end of file diff --git a/src/assets/catalogs/ZarrCatalog.ts b/src/assets/catalogs/ZarrCatalog.ts new file mode 100644 index 00000000..350d82d7 --- /dev/null +++ b/src/assets/catalogs/ZarrCatalog.ts @@ -0,0 +1,92 @@ +export const ZARR_CATALOG = [ + { + key: 'seasfire', + label: 'SeasFire Cube', + subtitle: 'A Global Dataset for Seasonal Fire Modeling in the Earth System', + store: 'https://s3.bgc-jena.mpg.de:9000/misc/seasfire_rechunked.zarr', + }, + { + key: 'ESDC', + label: 'ESDC', + subtitle: 'Earth System Data Cube v3.0.2', + store: 'https://s3.bgc-jena.mpg.de:9000/esdl-esdc-v3.0.2/esdc-16d-2.5deg-46x72x1440-3.0.2.zarr', + }, + { + key: 'precipitation', + label: 'Precipitation (EC-Earth3P-HR)', + subtitle: 'Precipitation data from EC-Earth3P-HR highresSST-present', + store: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/EC-Earth-Consortium/EC-Earth3P-HR/highresSST-present/r1i1p1f1/Amon/pr/gr/v20170811/', + }, + { + key: 'arco-ocean', + label: 'ARCO-OCEAN', + subtitle: 'Global Ocean Reanalysis Daily', + store: 'https://ogs-arco-ocean.s3.eu-south-1.amazonaws.com/dataset/tres=1d/res=0p25/levels=10/', + }, + { + key: 'cmip6-mpi-esm1-2-hr-tas', + label: 'CMIP6 MPI-ESM1-2-HR', + subtitle: 'Historical Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/CMIP/MPI-M/MPI-ESM1-2-HR/historical/r1i1p1f1/Amon/tas/gn/v20190710/', + }, + { + key: 'cmip6-pmip-ipsl-cm6a-lr-tas', + label: 'CMIP6 PMIP IPSL-CM6A-LR', + subtitle: 'Mid-Holocene Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/PMIP/IPSL/IPSL-CM6A-LR/midHolocene/r1i1p1f3/Amon/tas/gr/v20180926/', + }, + { + key: 'cmip6-hadgem3-gc31-hm-tas', + label: 'CMIP6 HighResMIP HadGEM3-GC31-HM', + subtitle: 'Near-Surface Air Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/MOHC/HadGEM3-GC31-HM/highresSST-present/r1i1p1f1/Amon/tas/gn/v20170831/', + }, + { + key: 'indian-ocean-chl', + label: 'Indian Ocean Physical and Biological Variables', + subtitle: '', + store: 'https://storage.googleapis.com/nmfs_odp_nwfsc/CB/mind_the_chl_gap/IO.zarr', + }, + { + key: 'nasa-mur-sst', + label: 'NASA MUR Sea Surface Temperature', + subtitle: 'Multi-scale Ultra-high Resolution SST', + store: 'https://mur-sst.s3.us-west-2.amazonaws.com/zarr-v1/', + }, + { + key: 'cmip6-gfdl-esm4-tos', + label: 'CMIP6 NOAA GFDL-ESM4', + subtitle: 'Historical Sea Surface Temperature', + store: 'https://storage.googleapis.com/cmip6/CMIP6/CMIP/NOAA-GFDL/GFDL-ESM4/historical/r2i1p1f1/Omon/tos/gn/v20180701/', + }, + { + key: 'cmip6-awi-cm-1-1-mr-tos', + label: 'CMIP6 AWI-CM-1-1-MR', + subtitle: 'Historical Daily Sea Surface Temperature', + store: 'https://cmip6-pds.s3.amazonaws.com/CMIP6/CMIP/AWI/AWI-CM-1-1-MR/historical/r1i1p1f1/Oday/tos/gn/v20181218/', + }, + { + key: 'carbonplan-antarctic-era5', + label: 'CarbonPlan Antarctic ERA5 Reanalysis', + subtitle: '', + store: 'https://carbonplan-share.s3.us-west-2.amazonaws.com/zarr-layer-examples/antarctic_era5.zarr', + }, + { + key: 'silam-dust', + label: 'SILAM Global Dust Model Forecasts', + subtitle: 'Dust', + store: 'https://data.source.coop/bkr/silam-dust/silam_global_dust_v3.zarr', + }, + { + key: 'silam-dust-v2', + label: 'SILAM Global Dust Model Forecasts (v2)', + subtitle: 'Dust', + store: 'https://data.source.coop/bkr/silam-dust/data.zarr', + }, + { + key: 'gefs-35day-direct', + label: 'NOAA GEFS 35-day Ensemble Forecast', + subtitle: '', + store: 'https://data.dynamical.org/noaa/gefs/forecast-35-day/latest.zarr', + }, +]; \ No newline at end of file diff --git a/src/assets/SeasFire-logo.png b/src/assets/imgs/SeasFire-logo.png similarity index 100% rename from src/assets/SeasFire-logo.png rename to src/assets/imgs/SeasFire-logo.png diff --git a/src/assets/line-graph.svg b/src/assets/imgs/line-graph.svg similarity index 100% rename from src/assets/line-graph.svg rename to src/assets/imgs/line-graph.svg diff --git a/src/assets/logo.png b/src/assets/imgs/logo.png similarity index 100% rename from src/assets/logo.png rename to src/assets/imgs/logo.png diff --git a/src/assets/logoS.png b/src/assets/imgs/logoS.png similarity index 100% rename from src/assets/logoS.png rename to src/assets/imgs/logoS.png diff --git a/src/assets/logo_bgc.png b/src/assets/imgs/logo_bgc.png similarity index 100% rename from src/assets/logo_bgc.png rename to src/assets/imgs/logo_bgc.png diff --git a/src/assets/logo_bgc_mpi.png b/src/assets/imgs/logo_bgc_mpi.png similarity index 100% rename from src/assets/logo_bgc_mpi.png rename to src/assets/imgs/logo_bgc_mpi.png diff --git a/src/assets/logo_mpi.png b/src/assets/imgs/logo_mpi.png similarity index 100% rename from src/assets/logo_mpi.png rename to src/assets/imgs/logo_mpi.png diff --git a/src/assets/logocut.png b/src/assets/imgs/logocut.png similarity index 100% rename from src/assets/logocut.png rename to src/assets/imgs/logocut.png diff --git a/src/assets/seasfire_logo.png b/src/assets/imgs/seasfire_logo.png similarity index 100% rename from src/assets/seasfire_logo.png rename to src/assets/imgs/seasfire_logo.png diff --git a/src/assets/settings.svg b/src/assets/imgs/settings.svg similarity index 100% rename from src/assets/settings.svg rename to src/assets/imgs/settings.svg diff --git a/src/assets/index.ts b/src/assets/index.ts index fb9ba350..4c1130b4 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -1,9 +1,11 @@ -import logoSeasFire from "./logoS.png"; -import logoSF from "./SeasFire-logo.png"; -import logo from "./logo.png"; -import logoBGC_MPI from "./logo_bgc_mpi.png" -import logoBGC from "./logo_bgc.png" -import logoMPI from "./logo_mpi.png" +import logoSeasFire from "./imgs/logoS.png"; +import logoSF from "./imgs/SeasFire-logo.png"; +import logo from "./imgs/logo.png"; +import logoBGC_MPI from "./imgs/logo_bgc_mpi.png" +import logoBGC from "./imgs/logo_bgc.png" +import logoMPI from "./imgs/logo_mpi.png" +import { ZARR_CATALOG } from "./catalogs/ZarrCatalog" +import { ICECHUNK_CATALOG } from "./catalogs/IcechunkCatalog" export { logoSeasFire, @@ -11,5 +13,7 @@ export { logoSF, logoBGC_MPI, logoBGC, - logoMPI + logoMPI, + ZARR_CATALOG, + ICECHUNK_CATALOG, }; \ No newline at end of file diff --git a/src/components/ui/MainPanel/CuratedDatasets.tsx b/src/components/ui/MainPanel/CuratedDatasets.tsx index 3db1c35f..689ced2d 100644 --- a/src/components/ui/MainPanel/CuratedDatasets.tsx +++ b/src/components/ui/MainPanel/CuratedDatasets.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useState } from 'react'; -import { DATASETS_COLLECTION } from './DatasetCollection'; +import { ZARR_CATALOG } from "@/assets/index"; + import { Command, CommandEmpty, @@ -30,7 +31,7 @@ const CuratedDatasets = ({ const [search, setSearch] = useState(''); const [showAll, setShowAll] = useState(false); - const filtered = DATASETS_COLLECTION.filter(ds => + const filtered = ZARR_CATALOG.filter(ds => ds.label.toLowerCase().includes(search.toLowerCase()) || ds.subtitle.toLowerCase().includes(search.toLowerCase()) ); diff --git a/src/components/ui/MainPanel/Dataset.tsx b/src/components/ui/MainPanel/Dataset.tsx index 0390599e..b9d7dcf7 100644 --- a/src/components/ui/MainPanel/Dataset.tsx +++ b/src/components/ui/MainPanel/Dataset.tsx @@ -12,10 +12,13 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { Separator } from '@/components/ui/separator'; import { DatasetOption, DescriptionContent } from './DescriptionContent'; import RemoteZarr from './RemoteZarr'; +import RemoteIcechunk from './RemoteIcechunk'; import LocalContent from './LocalContent'; import DatasetsModal from './DataSetsModal'; +import { Switcher } from '../Widgets/Switcher'; const Dataset = () => { const [showStoreInput, setShowStoreInput] = useState(false); @@ -26,6 +29,7 @@ const Dataset = () => { const [popoverOpen, setPopoverOpen] = useState(false); const [isSafari, setIsSafari] = useState(false); const [openDatasetsModal, setOpenDatasetsModal] = useState(false); + const [useIcechunk, setUseIcechunk] = useState(false); const { initStore, setInitStore, setOpenVariables } = useGlobalStore( useShallow(state => ({ @@ -102,36 +106,51 @@ const Dataset = () => { className="cursor-pointer w-full justify-between gap-2 mb-1" onClick={() => setOpenDatasetsModal(true)} > - {popoverSide !== 'top' && } + {popoverSide !== 'top' && } Explore Datasets - {popoverSide === 'top' && } + {popoverSide === 'top' && } -
-
{ - setShowStoreInput(prev => !prev); + const opening = !showStoreInput || activeOption !== 'remote'; + setShowStoreInput(opening); setShowLocalInput(false); setActiveOption('remote'); setShowDescription(false); + if (opening) setUseIcechunk(false); }} > - Remote Zarr + Remote {showStoreInput && (
- setShowDescription(true)} + setUseIcechunk(prev => !prev)} /> + {useIcechunk ? ( + setShowDescription(true)} + /> + ) : ( + setShowDescription(true)} + /> + )}
)}
+ +
{ setShowStoreInput(false); setActiveOption('local'); setShowDescription(false); + setUseIcechunk(false); }} > Local @@ -166,7 +186,6 @@ const Dataset = () => { />
)} -
diff --git a/src/components/ui/MainPanel/DatasetCollection.ts b/src/components/ui/MainPanel/DatasetCollection.ts deleted file mode 100644 index fb17ba2d..00000000 --- a/src/components/ui/MainPanel/DatasetCollection.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const ZARR_STORES = { - ESDC: 'https://s3.bgc-jena.mpg.de:9000/esdl-esdc-v3.0.2/esdc-16d-2.5deg-46x72x1440-3.0.2.zarr', - SEASFIRE: 'https://s3.bgc-jena.mpg.de:9000/misc/seasfire_rechunked.zarr', - PRECIPITATION: 'https://storage.googleapis.com/cmip6/CMIP6/HighResMIP/EC-Earth-Consortium/EC-Earth3P-HR/highresSST-present/r1i1p1f1/Amon/pr/gr/v20170811/', -}; - -export const DATASETS_COLLECTION = [ - { - key: 'seasfire', - label: 'SeasFire Cube', - subtitle: 'A Global Dataset for Seasonal Fire Modeling in the Earth System', - store: ZARR_STORES.SEASFIRE, - }, - { - key: 'ESDC', - label: 'ESDC', - subtitle: 'Earth System Data Cube v3.0.2', - store: ZARR_STORES.ESDC, - }, - { - key: 'precipitation', - label: 'Precipitation (EC-Earth3P-HR)', - subtitle: 'Precipitation data from EC-Earth3P-HR highresSST-present', - store: ZARR_STORES.PRECIPITATION, - }, -]; \ No newline at end of file diff --git a/src/components/ui/MainPanel/RemoteIcechunk.tsx b/src/components/ui/MainPanel/RemoteIcechunk.tsx index 0497f112..f96be577 100644 --- a/src/components/ui/MainPanel/RemoteIcechunk.tsx +++ b/src/components/ui/MainPanel/RemoteIcechunk.tsx @@ -283,7 +283,7 @@ const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => { Advanced {showAdvanced && ( -
+
@@ -14,7 +14,7 @@ export const Switcher = ({leftText, rightText, state, onClick, className} : {lef {rightText}
From 4512514bf841a5b6b4ebf264b2057a0d9c5e7222 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Sun, 21 Jun 2026 12:13:19 +0200 Subject: [PATCH 27/33] icechunk options into settings (#669) * mv into settings * parse as int instead --- .../ui/MainPanel/RemoteIcechunk.tsx | 262 ++++++++++-------- 1 file changed, 141 insertions(+), 121 deletions(-) diff --git a/src/components/ui/MainPanel/RemoteIcechunk.tsx b/src/components/ui/MainPanel/RemoteIcechunk.tsx index f96be577..44322417 100644 --- a/src/components/ui/MainPanel/RemoteIcechunk.tsx +++ b/src/components/ui/MainPanel/RemoteIcechunk.tsx @@ -97,6 +97,9 @@ type Props = { const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => { const [url, setUrl] = useState(''); + + const [showSettings, setShowSettings] = useState(false); + const [refType, setRefType] = useState('branch'); const [refValue, setRefValue] = useState('main'); @@ -169,143 +172,160 @@ const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => {
- {/* Branch / Tag / Snapshot */} -
-
- {REF_TABS.map(({ value, label }) => ( - - ))} -
- setRefValue(e.target.value)} - /> -
- - {/* Storage options */} + {/* Settings toggle */}
- {showStorage && ( -
-
- {/* Credentials Select */} -
- Credentials - -
+ {showSettings && ( +
- {/* Cache Select */} -
- Cache - -
- + {label} + + ))}
- + setRefValue(e.target.value)} + />
- )} -
- {/* fetchClient headers */} -
- - {showFetchClientHeaders && ( - - )} -
+ {/* Storage options */} +
+ + {showStorage && ( +
+
- {/* Advanced */} -
- - {showAdvanced && ( -
-
- - setMaxRetries(Number(e.target.value))} - /> -
-
- - setRetryDelay(Number(e.target.value))} - /> -
+ {/* Credentials Select */} +
+ Credentials + +
+ + {/* Cache Select */} +
+ Cache + +
+ +
+ +
+ )} +
+ + {/* fetchClient headers */} +
+ + {showFetchClientHeaders && ( + + )} +
+ + {/* Advanced */} +
+ + {showAdvanced && ( +
+
+ + setMaxRetries(Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ + setRetryDelay(Math.max(0, parseInt(e.target.value, 10) || 0))} + /> +
+
+ )}
+
)}
-
); }; From 3c21b0e35cdd0b701c3b67ff78eed427b6ef564a Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Sun, 21 Jun 2026 20:08:00 +0200 Subject: [PATCH 28/33] adds icechunk catalog (#670) * mv catalog to zarr fetch store in modal * wire-in catalogs * search colors * search * descriptions * states, redundancy * minimal keyboard arrows functionality --- .../ui/MainPanel/CuratedDatasets.tsx | 103 -------- src/components/ui/MainPanel/DataSetsModal.tsx | 117 ++++++--- .../ui/MainPanel/RemoteIcechunk.tsx | 7 +- src/components/ui/MainPanel/RemoteZarr.tsx | 39 +-- src/components/ui/MainPanel/StoreCatalog.tsx | 231 ++++++++++++++++++ 5 files changed, 338 insertions(+), 159 deletions(-) delete mode 100644 src/components/ui/MainPanel/CuratedDatasets.tsx create mode 100644 src/components/ui/MainPanel/StoreCatalog.tsx diff --git a/src/components/ui/MainPanel/CuratedDatasets.tsx b/src/components/ui/MainPanel/CuratedDatasets.tsx deleted file mode 100644 index 689ced2d..00000000 --- a/src/components/ui/MainPanel/CuratedDatasets.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import React, { useState } from 'react'; -import { ZARR_CATALOG } from "@/assets/index"; - -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { ChevronDown } from 'lucide-react'; - -const PAGE_SIZE = 5; - -type Props = { - activeOption: string; - setActiveOption: (key: string) => void; - setInitStore: (v: string) => void; - onOpenDescription: () => void; -}; - -const CuratedDatasets = ({ - activeOption, - setActiveOption, - setInitStore, - onOpenDescription, -}: Props) => { - const [search, setSearch] = useState(''); - const [showAll, setShowAll] = useState(false); - - const filtered = ZARR_CATALOG.filter(ds => - ds.label.toLowerCase().includes(search.toLowerCase()) || - ds.subtitle.toLowerCase().includes(search.toLowerCase()) - ); - - const visible = showAll ? filtered : filtered.slice(0, PAGE_SIZE); - const hiddenCount = filtered.length - PAGE_SIZE; - const hasMore = hiddenCount > 0; - - return ( - - { - setSearch(v); - setShowAll(false); - }} - /> - - No datasets found. - - {visible.map(ds => ( - { - setActiveOption(ds.key); - setInitStore(ds.store); - onOpenDescription(); - }} - className={`flex flex-col items-start gap-0.5 mb-2 cursor-pointer ${ - activeOption === ds.key ? 'bg-accent' : '' - }`} - > - {ds.label} - - {ds.subtitle} - - - ))} - - {!showAll && hasMore && ( - setShowAll(true)} - className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground cursor-pointer py-2 border-t border-dashed border-border mt-1 hover:text-foreground" - > - - {hiddenCount} more dataset{hiddenCount > 1 ? 's' : ''} available - - )} - - {showAll && hasMore && ( - setShowAll(false)} - className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground cursor-pointer py-2 border-t border-dashed border-border mt-1 hover:text-foreground" - > - - Show less - - )} - - - - ); -}; - -export default CuratedDatasets; \ No newline at end of file diff --git a/src/components/ui/MainPanel/DataSetsModal.tsx b/src/components/ui/MainPanel/DataSetsModal.tsx index 4a70513b..4dab59bf 100644 --- a/src/components/ui/MainPanel/DataSetsModal.tsx +++ b/src/components/ui/MainPanel/DataSetsModal.tsx @@ -5,27 +5,33 @@ import { useShallow } from 'zustand/shallow'; import { Dialog, DialogContent, + DialogHeader, + DialogDescription, DialogTitle, } from "@/components/ui/dialog"; -import { - ButtonGroup, -} from "@/components/ui/button-group"; +import { ButtonGroup } from "@/components/ui/button-group"; import { Button } from "@/components/ui/button-enhanced"; import { DescriptionContent } from './DescriptionContent'; -import CuratedDatasets from './CuratedDatasets'; +import StoreCatalog from './StoreCatalog'; +import { ZARR_CATALOG, ICECHUNK_CATALOG } from "@/assets/index"; import RemoteZarr from './RemoteZarr'; import LocalContent from './LocalContent'; import RemoteIcechunk from './RemoteIcechunk'; -type Tab = 'curated' | 'remote' | 'local' | 'icechunk'; +type Tab = 'remote' | 'local' | 'icechunk'; const TABS: { value: Tab; label: string }[] = [ - { value: 'curated', label: 'Curated' }, - { value: 'remote', label: 'Remote Zarr' }, - { value: 'icechunk', label: 'Icechunk' }, - { value: 'local', label: 'Local' }, + { value: 'remote', label: 'Remote Zarr' }, + { value: 'icechunk', label: 'Icechunk' }, + { value: 'local', label: 'Local' }, ]; +const TAB_DESCRIPTIONS: Record = { + remote: 'Browse and open remote Zarr stores via URL or from the catalog.', + icechunk: 'Connect to versioned Icechunk stores for transactional data access.', + local: 'Open a Zarr dataset stored on your local filesystem.', +}; + type Props = { open: boolean; onOpenChange: (v: boolean) => void; @@ -34,8 +40,10 @@ type Props = { const DatasetsModal = ({ open, onOpenChange, isSafari }: Props) => { const [activeOption, setActiveOption] = useState(''); + const [selectedUrl, setSelectedUrl] = useState(''); + const [selectedIcechunkUrl, setSelectedIcechunkUrl] = useState(''); const [hasFetched, setHasFetched] = useState(false); - const [activeTab, setActiveTab] = useState('curated'); + const [activeTab, setActiveTab] = useState('remote'); const { initStore, setInitStore, setOpenVariables, status } = useGlobalStore( useShallow(state => ({ @@ -47,25 +55,46 @@ const DatasetsModal = ({ open, onOpenChange, isSafari }: Props) => { ); const showDescription = hasFetched && status === null; - const openDescription = () => setHasFetched(true); const handleTabChange = (tab: Tab) => { setActiveTab(tab); setHasFetched(false); + setSelectedUrl(''); + setSelectedIcechunkUrl(''); + setActiveOption(''); + }; + + const handleOpenChange = (v: boolean) => { + if (!v) { + setSelectedUrl(''); + setSelectedIcechunkUrl(''); + setActiveOption(''); + setHasFetched(false); + } + onOpenChange(v); }; return ( - + - Open Dataset + + Open dataset + {TAB_DESCRIPTIONS[activeTab]} + - {/* Header */}
-

- Open dataset -

- + {!showDescription && ( + <> +

+ Open dataset +

+

+ {TAB_DESCRIPTIONS[activeTab]} +

+ + )} + {TABS.map(({ value, label }) => (
-
- {activeTab === 'curated' && ( - - )} +
{activeTab === 'remote' && ( - + <> + + + )} {activeTab === 'local' && ( { /> )} {activeTab === 'icechunk' && ( - + <> + + + )} {showDescription && ( { type Props = { setInitStore: (v: string) => void; onOpenDescription: () => void; + selectedUrl?: string }; -const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => { - const [url, setUrl] = useState(''); +const RemoteIcechunk = ({ setInitStore, onOpenDescription, selectedUrl = '' }: Props) => { + const [url, setUrl] = useState(selectedUrl); const [showSettings, setShowSettings] = useState(false); @@ -155,7 +156,7 @@ const RemoteIcechunk = ({ setInitStore, onOpenDescription }: Props) => {
{/* URL + Fetch */} -
+
, string> = { @@ -33,14 +32,16 @@ type Props = { initStore: string; setInitStore: (v: string) => void; onOpenDescription: () => void; + selectedUrl?: string; }; -const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { +const RemoteZarr = ({ initStore, setInitStore, onOpenDescription, selectedUrl = '' }: Props) => { const [showFetchOptions, setShowFetchOptions] = useState(false); const [preset, setPreset] = useState('none'); const [presetValue, setPresetValue] = useState(''); const [headers, setHeaders] = useState([{ key: '', value: '' }]); const [showCustom, setShowCustom] = useState(false); + const [urlValue, setUrlValue] = useState(selectedUrl); const addHeaderRow = () => setHeaders(h => [...h, { key: '', value: '' }]); const removeHeaderRow = (i: number) => setHeaders(h => h.filter((_, idx) => idx !== i)); @@ -73,17 +74,16 @@ const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { className="flex flex-col gap-3" onSubmit={(e: React.FormEvent) => { e.preventDefault(); - const input = e.currentTarget.elements[0] as HTMLInputElement; - const url = input.value; - if (!url) return; + if (!urlValue) return; const fetchHandler = buildFetchHandler(); - if (fetchHandler && url.startsWith('http://')) { + if (fetchHandler && urlValue.startsWith('http://')) { useGlobalStore.getState().setStatus('Error: Cannot send auth headers over plain HTTP — use HTTPS.'); return; } + const fetchOptions = { - ...(fetchHandler && { fetch: fetchHandler}), + ...(fetchHandler && { fetch: fetchHandler }), }; useZarrStore.getState().setIcechunkOptions(null); @@ -93,19 +93,24 @@ const RemoteZarr = ({ initStore, setInitStore, onOpenDescription }: Props) => { useGlobalStore.getState().setStatus('Fetching...'); useZarrStore.getState().bumpFetchKey(); - setInitStore(url); + setInitStore(urlValue); onOpenDescription(); }} > {/* URL + Fetch */} -
- +
+ setUrlValue(e.target.value)} + />
- {/* FetchOptions */} + {/* fetchOptions toggle */}
diff --git a/src/components/zarr/ZarrParser.ts b/src/components/zarr/ZarrParser.ts index a8cdef0d..61f8d8fd 100644 --- a/src/components/zarr/ZarrParser.ts +++ b/src/components/zarr/ZarrParser.ts @@ -1,6 +1,33 @@ import * as zarr from 'zarrita' -import { jsonDecodeObject, jsonEncodeObject } from 'node_modules/zarrita/dist/src/util'; +import { InvalidMetadataError } from 'zarrita'; +// jsonEncodeObject and jsonDecodeObject +// From zarrita/src/util.ts, better this, than rely on internals +export function jsonEncodeObject(o: Record): Uint8Array { + const str = JSON.stringify( + o, + (_key, value) => { + // JSON.stringify converts NaN/Infinity/-Infinity to null. + // Zarr v3 spec requires these as string representations. + if (typeof value === "number") { + if (Number.isNaN(value)) return "NaN"; + if (value === Infinity) return "Infinity"; + if (value === -Infinity) return "-Infinity"; + } + return value; + }, + 2, + ); + return new TextEncoder().encode(str); +} +export function jsonDecodeObject(bytes: Uint8Array) { + const str = new TextDecoder().decode(bytes); + try { + return JSON.parse(str); + } catch (cause) { + throw new InvalidMetadataError("Failed to decode JSON", { cause }); + } +} function is_meta_key(key: string) { return (key.endsWith(".zarray") || key.endsWith(".zgroup") ||