From cca0ec7686c3e336173dcfa73a7a7bc0e0c2dfdb Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 26 Jun 2026 11:00:59 +0200 Subject: [PATCH 1/5] dim select components --- src/components/ui/DimSlicer/DimSlicer.tsx | 406 ++++++++++++++++++ .../ui/DimSlicer/DimSlicerAxisToggle.tsx | 73 ++++ .../ui/DimSlicer/DimSlicerModeToggle.tsx | 67 +++ .../ui/DimSlicer/DimSlicerNumericControl.tsx | 36 ++ .../DimSlicerNumericInputWithStepper.tsx | 105 +++++ .../ui/DimSlicer/DimSlicerTimeControl.tsx | 65 +++ src/components/ui/DimSlicer/TimeCombobox.tsx | 100 +++++ src/components/ui/DimSlicer/index.ts | 2 + .../ui/MainPanel/MetaDimSelector.tsx | 355 +++++++++++++++ src/components/ui/MainPanel/Variables.tsx | 73 ++-- 10 files changed, 1249 insertions(+), 33 deletions(-) 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/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx new file mode 100644 index 00000000..21965e61 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -0,0 +1,406 @@ +'use client'; +import React, { useState, useCallback } from 'react'; +import { Slider } from '@/components/ui/slider'; +import { Trash2 } from 'lucide-react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import { DimSlicerAxisToggle } from './DimSlicerAxisToggle'; +import { DimSlicerModeToggle } from './DimSlicerModeToggle'; +import { DimSlicerNumericControl } from './DimSlicerNumericControl'; +import { DimSlicerTimeControl } from './DimSlicerTimeControl'; + +export type SelectionMode = 'scalar' | 'slice'; +export type Axis = 'x' | 'y' | 'z' | 'c'; + +export interface SliceSelectionState { + mode: SelectionMode; + scalar: string; + start: string; + stop: string; +} + +export function defaultSelection(dimSize?: number): SliceSelectionState { + const maxIndex = dimSize ? Math.max(dimSize - 1, 0) : 0; + return { mode: 'slice', scalar: '0', start: '0', stop: String(maxIndex) }; +} + +const MODE_ACCENT: Record = { + scalar: 'border-l-teal-700', + slice: 'border-l-[#644FF0]', +}; + +export interface DimOption { + name: string; + size: number; + values?: number[]; + formatValue?: (value: number) => string; +} + +export interface DimSlicerProps { + availableDims: DimOption[]; + dimName: string; + onDimChange: (dimName: string) => void; + onRemove?: () => void; + dimSize: number; + selection: SliceSelectionState; + onChange: (next: SliceSelectionState) => void; + step?: number; + axis?: Axis; + onAxisChange?: (axis: Axis) => void; + values?: number[]; + formatValue?: (value: number) => string; + /** If set, locks the mode and hides the mode toggle */ + lockMode?: SelectionMode; + /** If set, restricts which axes are shown in the axis toggle */ + allowedAxes?: Axis[]; +} + +const DimSlicer: React.FC = ({ + availableDims, + dimName, + onDimChange, + onRemove, + dimSize, + selection, + onChange, + step = 1, + axis: propAxis = 'x', + onAxisChange, + values, + formatValue, + lockMode, + allowedAxes, +}) => { + // const [currentAxis, setCurrentAxis] = useState(propAxis); + const effectiveDimSize = values ? values.length : dimSize; + const rawSel = selection ?? defaultSelection(effectiveDimSize); + const sel = lockMode ? { ...rawSel, mode: lockMode } : rawSel; + + const getIndexFromValue = (val: number): number => { + if (!values) return 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 maxIndex = Math.max(effectiveDimSize - 1, 0); + + const changeScalarBy = (delta: number) => { + let val = parseOr(sel.scalar, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, scalar: String(val) }); + }; + + const changeStartBy = (delta: number) => { + let val = parseOr(sel.start, 0) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, start: String(val) }); + }; + + const changeStopBy = (delta: number) => { + let val = parseOr(sel.stop, maxIndex) + delta; + val = clamp(val, 0, maxIndex); + onChange({ ...sel, stop: String(val) }); + }; + + const updateSelection = (patch: Partial) => { + const next = { ...sel, ...patch }; + if (lockMode) next.mode = lockMode; + onChange(next); + }; + + const startIndex = clamp(parseOr(sel.start, 0), 0, maxIndex); + const stopIndex = clamp(parseOr(sel.stop, maxIndex), 0, maxIndex); + const scalarIndex = clamp(parseOr(sel.scalar, 0), 0, maxIndex); + + const startValue = values && effectiveDimSize > 0 && startIndex < values.length ? String(values[startIndex]) : sel.start; + const stopValue = values && effectiveDimSize > 0 && stopIndex < values.length ? String(values[stopIndex]) : sel.stop; + const scalarValue = values && effectiveDimSize > 0 && scalarIndex < values.length ? String(values[scalarIndex]) : sel.scalar; + + const formattedValue = useCallback( + (index: number) => + values && effectiveDimSize > 0 && index < values.length + ? String(formatValue ? formatValue(values[index]) : values[index].toString()) + : String(index), + [values, effectiveDimSize, formatValue] + ); + + const isTimeDimension = dimName.toLowerCase().includes('time'); + const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date'); + const showTimeControls = Boolean(values && isTimeDimension); + + return ( +
+ + {onRemove && ( + + )} + + {/* Top row: dim select + mode toggle + axis toggle */} +
+ + +
+ {!lockMode && ( + updateSelection({ mode })} + /> + )} + {sel.mode === 'slice' && ( + + {propAxis} + + )} +
+
+ + {/* Slider */} + {sel.mode === 'slice' && ( +
+ + updateSelection({ start: String(newStart), stop: String(newStop) }) + } + className="w-full cursor-pointer [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {sel.mode === 'scalar' && ( +
+ updateSelection({ scalar: String(val) })} + className="w-full cursor-pointer [&_[data-slot=slider-range]]:bg-transparent [&_[data-slot=slider-track]]:h-0.5 [&_[data-slot=slider-thumb]]:h-3 [&_[data-slot=slider-thumb]]:w-3" + /> +
+ )} + + {/* Bottom controls */} + {isDateDimension ? ( + sel.mode === 'scalar' ? ( +
+ updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> +
+ ) : ( +
+ updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> +
+ updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> +
+
+ ) + ) : ( +
+ {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ start: String(newStart) })} + value={startValue} + placeholder={formattedValue(0)} + ariaLabel="Start value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ start: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStartBy(+1)} + onDecrement={() => changeStartBy(-1)} + ariaLabel="Start value" + showInput={!isDateDimension} + /> + ) + ) : ( +
+ )} + + {sel.mode === 'slice' ? ( + showTimeControls ? ( + updateSelection({ stop: String(newStop) })} + value={stopValue} + placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))} + ariaLabel="Stop value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + includeEnd + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ stop: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeStopBy(+1)} + onDecrement={() => changeStopBy(-1)} + ariaLabel="Stop value" + showInput={!isDateDimension} + /> + ) + ) : showTimeControls ? ( + updateSelection({ scalar: String(newScalar) })} + value={scalarValue} + placeholder={formattedValue(0)} + ariaLabel="Scalar value" + values={values ?? []} + effectiveDimSize={effectiveDimSize} + formattedValue={formattedValue} + onValueChange={value => { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + /> + ) : ( + { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) updateSelection({ scalar: String(getIndexFromValue(parsed)) }); + }} + onIncrement={() => changeScalarBy(+1)} + onDecrement={() => changeScalarBy(-1)} + ariaLabel="Scalar value" + showInput={!isDateDimension} + /> + )} +
+ )} +
+ ); +}; + +export { DimSlicer }; +export default DimSlicer; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx new file mode 100644 index 00000000..a58adac8 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -0,0 +1,73 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type Axis = 'x' | 'y' | 'z' | 'c'; + +const AXIS_CLASS: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +interface DimSlicerAxisToggleProps { + axis: Axis; + onAxisChange?: (axis: Axis) => void; + /** If provided, only these axes are shown. Defaults to all four. */ + allowedAxes?: Axis[]; +} + +export const DimSlicerAxisToggle: React.FC = ({ + axis, + onAxisChange, + allowedAxes, +}) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + const axisOptions: Axis[] = allowedAxes ?? ['x', 'y', 'z', 'c']; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + {axisOptions.map(a => ( + + ))} + + ) : ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx new file mode 100644 index 00000000..30a15f4a --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -0,0 +1,67 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; + +export type SelectionMode = 'scalar' | 'slice'; + +interface DimSlicerModeToggleProps { + mode: SelectionMode; + onModeChange: (nextMode: SelectionMode) => void; +} + +export const DimSlicerModeToggle: React.FC = ({ mode, onModeChange }) => { + const [expanded, setExpanded] = useState(false); + const rootRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setExpanded(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {expanded ? ( + + + + + ) : ( + + )} +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx new file mode 100644 index 00000000..2a9494b6 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericControl.tsx @@ -0,0 +1,36 @@ +'use client' + +import React from 'react' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerNumericControlProps { + value: string + placeholder: string + ariaLabel: string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + showInput: boolean +} + +export function DimSlicerNumericControl({ + value, + placeholder, + ariaLabel, + onValueChange, + onIncrement, + onDecrement, + showInput, +}: DimSlicerNumericControlProps) { + return ( + + ) +} diff --git a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx new file mode 100644 index 00000000..4cd1e36b --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -0,0 +1,105 @@ +'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 [localValue, setLocalValue] = useState(value); + const isFocused = useRef(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); + }, []); + + // Sync prop changes to local state when not focused + useEffect(() => { + if (!isFocused.current) { + setLocalValue(value); + } + }, [value]); + + const handleFocus = () => { + isFocused.current = true; + }; + + const handleBlur = () => { + isFocused.current = false; + onValueChange(localValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onValueChange(localValue); + } + }; + + return ( +
+ + {showInput && ( + setLocalValue(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + onClick={() => setExpanded(false)} + className="no-spinner h-7 text-xs w-16 text-center appearance-none" + placeholder={placeholder} + aria-label={ariaLabel} + /> + )} + {expanded ? ( + + + + + ) : ( + + )} + +
+ ); +}; diff --git a/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx new file mode 100644 index 00000000..af9e6285 --- /dev/null +++ b/src/components/ui/DimSlicer/DimSlicerTimeControl.tsx @@ -0,0 +1,65 @@ +'use client' + +import React from 'react' +import TimeCombobox from './TimeCombobox' +import { DimSlicerNumericInputWithStepper } from './DimSlicerNumericInputWithStepper' + +interface DimSlicerTimeControlProps { + currentIndex: number + onIndexChange: (index: number) => void + value: string + placeholder: string + ariaLabel: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + onValueChange: (value: string) => void + onIncrement: () => void + onDecrement: () => void + includeEnd?: boolean + layout?: 'row' | 'column' + showInput?: boolean +} + +export function DimSlicerTimeControl({ + currentIndex, + onIndexChange, + value, + placeholder, + ariaLabel, + values, + effectiveDimSize, + formattedValue, + onValueChange, + onIncrement, + onDecrement, + includeEnd = false, + layout = 'column', + showInput = true, +}: DimSlicerTimeControlProps) { + return ( +
+
+ +
+ +
+ ) +} diff --git a/src/components/ui/DimSlicer/TimeCombobox.tsx b/src/components/ui/DimSlicer/TimeCombobox.tsx new file mode 100644 index 00000000..b15c8f5c --- /dev/null +++ b/src/components/ui/DimSlicer/TimeCombobox.tsx @@ -0,0 +1,100 @@ +'use client' +import React, { useState, useMemo } from 'react' +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox' + +interface TimeComboboxProps { + currentIndex: number + onIndexChange: (index: number) => void + ariaLabel: string + placeholder: string + values: number[] + effectiveDimSize: number + formattedValue: (index: number) => string + includeEnd?: boolean +} + +export default function TimeCombobox({ + currentIndex, + onIndexChange, + ariaLabel, + placeholder, + values, + effectiveDimSize, + formattedValue, + includeEnd = false, +}: TimeComboboxProps) { + const selectedLabel = + includeEnd && currentIndex === effectiveDimSize + ? formattedValue(Math.max(effectiveDimSize - 1, 0)) + : formattedValue(currentIndex) + + const [inputQuery, setInputQuery] = useState('') + const inputValue = inputQuery === '' ? selectedLabel : inputQuery + + const handleValueChange = (value: unknown) => { + const label = typeof value === 'string' ? value : '' + if (label === '') { + setInputQuery('') + onIndexChange(includeEnd ? effectiveDimSize : 0) + return + } + const item = labeledValues.find(({ label: l }) => l === label) + if (item) { + setInputQuery('') + onIndexChange(item.index) + } + } + + const labeledValues = useMemo(() => { + return values.map((_, i) => ({ label: formattedValue(i), index: i })) + }, [values, formattedValue]) + + const filtered = useMemo(() => { + const normalizedInput = inputValue.trim().toLowerCase() + const selectedQuery = selectedLabel.trim().toLowerCase() + return normalizedInput === '' || normalizedInput === selectedQuery + ? labeledValues + : labeledValues.filter(({ label }) => label.toLowerCase().includes(normalizedInput)) + }, [inputValue, selectedLabel, labeledValues]) + + const targetWidth = Math.min( + Math.max(Math.max(selectedLabel.length, placeholder.length) + 2, 12), + 20 + ) + + return ( + + setInputQuery(typeof value === 'string' ? value : String(value)) + } + autoHighlight + > + + + {filtered.length === 0 ? No items found. : null} + + {filtered.map(({ label, index }) => ( + + {label} + + ))} + + + + ) +} \ No newline at end of file diff --git a/src/components/ui/DimSlicer/index.ts b/src/components/ui/DimSlicer/index.ts new file mode 100644 index 00000000..0622ada3 --- /dev/null +++ b/src/components/ui/DimSlicer/index.ts @@ -0,0 +1,2 @@ +export { default } from './DimSlicer'; +export * from './DimSlicer'; diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx new file mode 100644 index 00000000..3f74a99c --- /dev/null +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -0,0 +1,355 @@ +"use client"; + +import React, { useMemo, useState } from 'react'; +import DimSlicer, { Axis, defaultSelection, DimOption, SliceSelectionState } from '@/components/ui/DimSlicer'; +import { defaultAttributes, renderAttributes } from "@/components/ui/MetaData"; +import { Button } from '@/components/ui/button'; +import { useGlobalStore } from '@/GlobalStates/GlobalStore'; +import { useShallow } from 'zustand/shallow'; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; +import { Badge } from "@/components/ui"; +import { parseLoc } from '@/utils/HelperFuncs'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const MAX_ACTIVE_DIMS = 3; +const DEFAULT_AXES: Axis[] = ['z', 'y', 'x']; + +const formatArray = (value: string | number[]): string => { + if (typeof value === 'string') return value; + return Array.isArray(value) ? value.join(', ') : String(value); +}; + +interface DimInfo { + dimArrays: ArrayLike[]; + dimNames: string[]; + dimUnits: (string | null)[]; +} + +type Props = { + meta: { + name?: string; + shape?: number[]; + chunks?: number[]; + long_name?: string; + dimInfo?: DimInfo; + [key: string]: unknown; + }; + metadata?: Record; + onApply?: (sels: SliceSelectionState[], axes: Axis[], dimNames: string[]) => void; +}; + +const AXIS_COLOR: Record = { + x: 'text-pink-500', + y: 'text-green-500', + z: 'text-blue-500', + c: 'text-yellow-500', +}; + +function axisForIndex(idx: number): Axis { + return DEFAULT_AXES[idx] ?? DEFAULT_AXES[DEFAULT_AXES.length - 1]; +} + +function selectionSummary( + availableDims: DimOption[], + activeRows: SlicerRow[], + collapsedSels: Record, +): string { + const parts = availableDims.map((dim) => { + const activeRow = activeRows.find((r) => r.dimName === dim.name); + const sel = activeRow ? activeRow.sel : collapsedSels[dim.name]; + if (!sel) return `${dim.name}=?`; + const range = + sel.mode === 'scalar' + ? sel.scalar || '0' + : `${sel.start !== '' ? sel.start : '0'}:${sel.stop !== '' ? sel.stop : ':'}`; + return `${dim.name}=${range}`; + }); + return `[ ${parts.join(', ')} ]`; +} + +interface SlicerRow { + id: number; + dimName: string; + sel: SliceSelectionState; + axis: Axis; +} + +let _nextId = 0; +const nextId = () => ++_nextId; + +export default function MetaDimSelector({ meta, metadata, onApply }: Props) { + const rawDimArrays = meta?.dimInfo?.dimArrays ?? []; + const rawDimNames = meta?.dimInfo?.dimNames ?? []; + const rawDimUnits = meta?.dimInfo?.dimUnits ?? []; + const dataShape = meta?.shape; + const chunkShape = meta?.chunks; + + const dimArrays: number[][] = useMemo( + () => rawDimArrays.map((a) => Array.from(a)), + [rawDimArrays], + ); + const dimUnits: string[] = useMemo( + () => rawDimUnits.map((u) => u ?? ''), + [rawDimUnits], + ); + const dimNames: string[] = rawDimNames; + + const { setDimArrays, setDimNames, setDimUnits } = useGlobalStore( + useShallow((state) => ({ + setDimArrays: state.setDimArrays, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, + })), + ); + + React.useEffect(() => { + setDimArrays(dimArrays); + setDimNames(dimNames); + setDimUnits(dimUnits); + }, [dimArrays, dimNames, dimUnits, setDimArrays, setDimNames, setDimUnits]); + + const availableDims: DimOption[] = useMemo( + () => + dimArrays.map((values, idx) => ({ + name: dimNames[idx] ?? `dim${idx}`, + size: values.length, + values, + formatValue: (v: number): string => + String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), + })), + [dimArrays, dimNames, dimUnits], + ); + + const dimsKey = availableDims.map((d) => `${d.name}:${d.size}`).join('|'); + + const makeInitialCollapsedSels = (dims: DimOption[]): Record => + Object.fromEntries(dims.map((d) => [d.name, { ...defaultSelection(d.size), mode: 'scalar' as const }])); + + /** Default to first MIN(3, availableDims.length) dims as active rows */ + const makeInitialRows = (dims: DimOption[]): SlicerRow[] => + dims.slice(0, MAX_ACTIVE_DIMS).map((d, i) => ({ + id: nextId(), + dimName: d.name, + sel: { ...defaultSelection(d.size), mode: 'slice' }, + axis: axisForIndex(i), + })); + + const [rows, setRows] = useState(() => makeInitialRows(availableDims)); + const [collapsedSels, setCollapsedSels] = useState>( + () => makeInitialCollapsedSels(availableDims), + ); + const [lastKey, setLastKey] = useState(dimsKey); + const [collapsedOpen, setCollapsedOpen] = useState(false); + + if (dimsKey !== lastKey) { + setLastKey(dimsKey); + setRows(makeInitialRows(availableDims)); + setCollapsedSels(makeInitialCollapsedSels(availableDims)); + } + + const firstUnusedDim = (currentRows: SlicerRow[]): string => { + const usedNames = new Set(currentRows.map((r) => r.dimName)); + return availableDims.find((d) => !usedNames.has(d.name))?.name ?? ''; + }; + + const addRow = () => { + setRows((prev) => { + if (prev.length >= MAX_ACTIVE_DIMS) return prev; + const dimName = firstUnusedDim(prev); + if (!dimName) return prev; + const dim = availableDims.find((d) => d.name === dimName)!; + const newRow: SlicerRow = { + id: nextId(), + dimName, + sel: { ...defaultSelection(dim.size), mode: 'slice' }, + axis: axisForIndex(prev.length), + }; + return [...prev, newRow]; + }); + }; + + const removeLastRow = () => + setRows((prev) => prev.slice(0, -1)); + + const updateDimName = (id: number, dimName: string) => { + setRows((prev) => + prev.map((r) => { + if (r.id !== id) return r; + const dim = availableDims.find((d) => d.name === dimName); + return { ...r, dimName, sel: { ...defaultSelection(dim?.size), mode: 'slice' } }; + }), + ); + }; + + const updateSel = (id: number, sel: SliceSelectionState) => + setRows((prev) => prev.map((r) => (r.id === id ? { ...r, sel: { ...sel, mode: 'slice' } } : r))); + + // const updateAxis = (id: number, axis: Axis) => + // setRows((prev) => prev.map((r) => (r.id === id ? { ...r, axis } : r))); + + const updateCollapsedSel = (dimName: string, sel: SliceSelectionState) => + setCollapsedSels((prev) => ({ ...prev, [dimName]: { ...sel, mode: 'scalar' } })); + + const summary = useMemo( + () => selectionSummary(availableDims, rows, collapsedSels), + [availableDims, rows, collapsedSels], + ); + + const activeDimNames = new Set(rows.map((r) => r.dimName)); + const collapsedDims = availableDims.filter((d) => !activeDimNames.has(d.name)); + + const atMax = rows.length >= MAX_ACTIVE_DIMS; + const noUnused = firstUnusedDim(rows) === ''; + const canAdd = !atMax && !noUnused; + + const addTooltip = atMax + ? `Maximum of ${MAX_ACTIVE_DIMS} dimensions, remove one before adding another.` + : noUnused + ? 'All dimensions are already active.' + : undefined; + + return ( + <> + {`${meta.long_name} `} + + + Attributes + + + {renderAttributes(metadata, defaultAttributes)} + + +
+ +
+ {'selection'} {summary} +
+ +
+ {rows.map((row) => { + const originalIndex = availableDims.findIndex( + (d) => d.name === row.dimName + ); + + return ( + + ( + {row.dimName} + ,{' '} + {row.axis} + ,{' '} + + {originalIndex} + + ) + + ); + })} +
+ +
+
+ Data Shape + {`[${formatArray(dataShape ?? [])}]`} +
+
+ Chunk Shape + {`[${formatArray(chunkShape ?? [])}]`} +
+
+ +
+ + + {addTooltip && ( +
+
+ {addTooltip} +
+
+ )} +
+ + {/* Active slicers — locked to slice, z/y/x axes only, trash only on last */} +
+ {rows.map((row, i) => { + const dim = availableDims.find((d) => d.name === row.dimName); + const isLast = i === rows.length - 1; + return ( + updateDimName(row.id, name)} + onRemove={isLast && rows.length > 1 ? removeLastRow : undefined} + dimSize={dim?.size ?? 0} + selection={row.sel} + axis={row.axis} + onChange={(sel) => updateSel(row.id, sel)} + // onAxisChange={(axis) => updateAxis(row.id, axis)} + values={dim?.values} + formatValue={dim?.formatValue} + lockMode="slice" + allowedAxes={['z', 'y', 'x']} + /> + ); + })} +
+ + {/* Collapsed dimensions */} + {collapsedDims.length > 0 && ( +
+ + + {collapsedOpen && ( +
+ {collapsedDims.map((dim) => ( + {}} + dimSize={dim.size} + selection={collapsedSels[dim.name] ?? { ...defaultSelection(dim.size), mode: 'scalar' }} + axis="c" + onChange={(sel) => updateCollapsedSel(dim.name, sel)} + values={dim.values} + formatValue={dim.formatValue} + lockMode="scalar" + /> + ))} +
+ )} +
+ )} + +
+ +
+ + ); +} \ No newline at end of file diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index c3564fb8..694ec24d 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -5,7 +5,7 @@ import { TbVariable } from "react-icons/tb"; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { useShallow } from "zustand/shallow"; import { Separator } from "@/components/ui/separator"; -import MetaDataInfo from "./MetaDataInfo"; +import MetaDimSelector from "./MetaDimSelector"; import { GetDimInfo } from "@/utils/HelperFuncs"; import { GetAttributes } from "@/components/zarr/ZarrLoaderLRU"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; @@ -34,7 +34,7 @@ const Variables = () => { const [openMetaPopover, setOpenMetaPopover] = useState(false); const [showMeta, setShowMeta] = useState(false); - const { variables, zMeta, metadata, initStore, openVariables, setMetadata, setOpenVariables } = useGlobalStore( + const { variables, zMeta, metadata, initStore, openVariables, setMetadata, setOpenVariables } = useGlobalStore( useShallow((state) => ({ variables: state.variables, zMeta: state.zMeta, @@ -46,9 +46,7 @@ const Variables = () => { })) ); - const [dimArrays, setDimArrays] = useState([[0],[0],[0]]); - const [dimUnits, setDimUnits] = useState([null,null,null]); - const [dimNames, setDimNames] = useState(["Default"]); + const [selectedVar, setSelectedVar] = useState(null); const [meta, setMeta] = useState(null); @@ -110,10 +108,24 @@ const Variables = () => { // Handle variable selection const handleVariableSelect = (val: string, idx: number) => { setSelectedVar(val); - GetDimInfo(val).then(e => { - setDimNames(e.dimNames); - setDimArrays(e.dimArrays); - setDimUnits(e.dimUnits); + setMeta(null); + setMetadata(null); + + Promise.all([GetDimInfo(val), GetAttributes(val)]).then(([dimInfo, attr]) => { + const relevant = zMeta?.find((e: any) => e.name === val); + if (relevant) { + setMeta({ + ...relevant, + dimInfo: { + dimArrays: dimInfo.dimArrays, + dimNames: dimInfo.dimNames, + dimUnits: dimInfo.dimUnits, + }, + }); + } + setMetadata(attr); + }).catch((err) => { + console.error("Failed to fetch dimension info or attributes:", err); }); if (popoverSide === "left") { @@ -124,20 +136,10 @@ const Variables = () => { }; useEffect(() => { - if (variables && zMeta && selectedVar) { - const relevant = zMeta.find((e: any) => e.name === selectedVar); - if (relevant){ - setMeta({...relevant, dimInfo : {dimArrays, dimNames, dimUnits}}); - GetAttributes(selectedVar).then(e=>setMetadata(e)); - } - } - }, [selectedVar, variables, zMeta, dimArrays, dimNames, dimUnits]); - - useEffect(()=>{ setSelectedVar(null); setMeta(null); setMetadata(null); - },[initStore, setMetadata]); + }, [initStore, setMetadata]); useEffect(() => { const handleResize = () => { @@ -315,15 +317,19 @@ const Variables = () => { data-meta-popover side="left" align="start" - className="max-h-[80vh] overflow-y-auto w-[300px]" + className="max-h-[80vh] overflow-y-auto w-[400px]" > - {meta && ( - { + // close UI after applying selections + setOpenMetaPopover(false); + setOpenVariables(false); + // future: persist sels/axes to store + console.log('Applied slices', sels, axes); + }} /> )} @@ -332,15 +338,16 @@ const Variables = () => { {popoverSide === "top" && ( - {} + { }
{meta && ( - { + setShowMeta(false); + setOpenVariables(false); + console.log('Applied slices', sels, axes); + }} /> )}
From 63efcb3fce5912c40900bbcfd13e95eda560f724 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 26 Jun 2026 11:09:36 +0200 Subject: [PATCH 2/5] fix value shown --- src/components/ui/DimSlicer/DimSlicer.tsx | 4 +++- .../DimSlicerNumericInputWithStepper.tsx | 20 ++++++------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index 21965e61..c12265f4 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -83,7 +83,9 @@ const DimSlicer: React.FC = ({ const sel = lockMode ? { ...rawSel, mode: lockMode } : rawSel; const getIndexFromValue = (val: number): number => { - if (!values) return val; + if (!values) { + return clamp(Math.round(val / step) * step, 0, maxIndex); + } let closestIndex = 0; let minDiff = Math.abs(values[0] - val); for (let i = 1; i < values.length; i++) { diff --git a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx index 4cd1e36b..c99f0915 100644 --- a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -26,7 +26,6 @@ export const DimSlicerNumericInputWithStepper: React.FC { const [expanded, setExpanded] = useState(false); const [localValue, setLocalValue] = useState(value); - const isFocused = useRef(false); const rootRef = useRef(null); useEffect(() => { @@ -40,25 +39,19 @@ export const DimSlicerNumericInputWithStepper: React.FC document.removeEventListener('mousedown', handleClickOutside); }, []); - // Sync prop changes to local state when not focused + // Sync prop changes to local state useEffect(() => { - if (!isFocused.current) { - setLocalValue(value); - } + setLocalValue(value); }, [value]); - const handleFocus = () => { - isFocused.current = true; - }; - - const handleBlur = () => { - isFocused.current = false; + const commitValue = () => { onValueChange(localValue); + setLocalValue(value); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - onValueChange(localValue); + commitValue(); } }; @@ -70,8 +63,7 @@ export const DimSlicerNumericInputWithStepper: React.FC setLocalValue(e.target.value)} - onFocus={handleFocus} - onBlur={handleBlur} + onBlur={commitValue} onKeyDown={handleKeyDown} onClick={() => setExpanded(false)} className="no-spinner h-7 text-xs w-16 text-center appearance-none" From 9e6469678eb39528d631d12655be89be5eefe09f Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 26 Jun 2026 11:29:05 +0200 Subject: [PATCH 3/5] fixes defaults keys --- src/components/ui/DimSlicer/DimSlicer.tsx | 16 ++++---- .../ui/MainPanel/MetaDimSelector.tsx | 39 +++++++++++-------- src/components/ui/MainPanel/Variables.tsx | 2 + 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index c12265f4..8f4cb34b 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -37,6 +37,7 @@ const MODE_ACCENT: Record = { export interface DimOption { name: string; + label?: string; size: number; values?: number[]; formatValue?: (value: number) => string; @@ -172,8 +173,8 @@ const DimSlicer: React.FC = ({ {availableDims.map((d) => ( - - {d.name} + + {d.label ?? d.name} ))} @@ -187,12 +188,11 @@ const DimSlicer: React.FC = ({ /> )} {sel.mode === 'slice' && ( - + {propAxis} )} diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 3f74a99c..1ea171aa 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -79,9 +79,9 @@ 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 rawDimNames = meta?.dimInfo?.dimNames ?? []; + const rawDimUnits = meta?.dimInfo?.dimUnits ?? []; + const dataShape = meta?.shape; const chunkShape = meta?.chunks; const dimArrays: number[][] = useMemo( @@ -97,8 +97,8 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { const { setDimArrays, setDimNames, setDimUnits } = useGlobalStore( useShallow((state) => ({ setDimArrays: state.setDimArrays, - setDimNames: state.setDimNames, - setDimUnits: state.setDimUnits, + setDimNames: state.setDimNames, + setDimUnits: state.setDimUnits, })), ); @@ -110,13 +110,20 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { const availableDims: DimOption[] = useMemo( () => - dimArrays.map((values, idx) => ({ - name: dimNames[idx] ?? `dim${idx}`, - size: values.length, - values, - formatValue: (v: number): string => - String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), - })), + dimArrays.map((values, idx) => { + const baseName = dimNames[idx] ?? `dim${idx}`; + // Always include idx to guarantee uniqueness during the "Default" fallback phase + const name = `${baseName}::${idx}`; + const label = baseName; + return { + name, + label, + size: values.length, + values, + formatValue: (v: number): string => + String(parseLoc(values[v] ?? v, dimUnits[idx] || undefined)), + }; + }), [dimArrays, dimNames, dimUnits], ); @@ -205,8 +212,8 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { const addTooltip = atMax ? `Maximum of ${MAX_ACTIVE_DIMS} dimensions, remove one before adding another.` : noUnused - ? 'All dimensions are already active.' - : undefined; + ? 'All dimensions are already active.' + : undefined; return ( <> @@ -309,7 +316,7 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { /> ); })} -
+ {/* Collapsed dimensions */} {collapsedDims.length > 0 && ( @@ -330,7 +337,7 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { key={dim.name} availableDims={availableDims} dimName={dim.name} - onDimChange={() => {}} + onDimChange={() => { }} dimSize={dim.size} selection={collapsedSels[dim.name] ?? { ...defaultSelection(dim.size), mode: 'scalar' }} axis="c" diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index 694ec24d..391605b1 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -321,6 +321,7 @@ const Variables = () => { > {metadata && ( { @@ -342,6 +343,7 @@ const Variables = () => {
{meta && ( { setShowMeta(false); From a7493a100e8181181b04d30a1b8875aabad8b22c Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 26 Jun 2026 11:35:49 +0200 Subject: [PATCH 4/5] enhanced button --- src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx | 2 +- src/components/ui/DimSlicer/DimSlicerModeToggle.tsx | 2 +- .../ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx | 2 +- src/components/ui/MainPanel/MetaDimSelector.tsx | 7 +++++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx index a58adac8..e4f1baf0 100644 --- a/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerAxisToggle.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@/components/ui/button-enhanced'; import { ButtonGroup } from '@/components/ui/button-group'; export type Axis = 'x' | 'y' | 'z' | 'c'; diff --git a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx index 30a15f4a..14af38ec 100644 --- a/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx +++ b/src/components/ui/DimSlicer/DimSlicerModeToggle.tsx @@ -1,6 +1,6 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; -import { Button } from '@/components/ui/button'; +import { Button } from '@/components/ui/button-enhanced'; import { ButtonGroup } from '@/components/ui/button-group'; export type SelectionMode = 'scalar' | 'slice'; diff --git a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx index c99f0915..3b9aa9a0 100644 --- a/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx +++ b/src/components/ui/DimSlicer/DimSlicerNumericInputWithStepper.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; +import { Button } from '@/components/ui/button-enhanced'; import { ButtonGroup } from '@/components/ui/button-group'; import { MinusIcon, PlusIcon } from 'lucide-react'; diff --git a/src/components/ui/MainPanel/MetaDimSelector.tsx b/src/components/ui/MainPanel/MetaDimSelector.tsx index 1ea171aa..d5a3454b 100644 --- a/src/components/ui/MainPanel/MetaDimSelector.tsx +++ b/src/components/ui/MainPanel/MetaDimSelector.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useState } from 'react'; import DimSlicer, { Axis, defaultSelection, DimOption, SliceSelectionState } from '@/components/ui/DimSlicer'; import { defaultAttributes, renderAttributes } from "@/components/ui/MetaData"; -import { Button } from '@/components/ui/button'; +import { Button } from '@/components/ui/button-enhanced'; import { useGlobalStore } from '@/GlobalStates/GlobalStore'; import { useShallow } from 'zustand/shallow'; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; @@ -353,7 +353,10 @@ export default function MetaDimSelector({ meta, metadata, onApply }: Props) { )}
-
From f13f165808c254d05a3362fbdf99c40453151b86 Mon Sep 17 00:00:00 2001 From: Lazaro Alonso Date: Fri, 26 Jun 2026 11:38:33 +0200 Subject: [PATCH 5/5] more keys --- src/components/ui/DimSlicer/DimSlicer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/DimSlicer/DimSlicer.tsx b/src/components/ui/DimSlicer/DimSlicer.tsx index 8f4cb34b..119e9925 100644 --- a/src/components/ui/DimSlicer/DimSlicer.tsx +++ b/src/components/ui/DimSlicer/DimSlicer.tsx @@ -173,7 +173,7 @@ const DimSlicer: React.FC = ({ {availableDims.map((d) => ( - + {d.label ?? d.name} ))}