Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
374 changes: 374 additions & 0 deletions src/components/ui/DimSlicer/DimSlicer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
'use client';
import React, { useState } from 'react';
import { Slider } from '@/components/ui/slider';

import { DimSlicerAxisToggle } from './DimSlicerAxisToggle';
import { DimSlicerModeToggle } from './DimSlicerModeToggle';
import { DimSlicerNumericControl } from './DimSlicerNumericControl';
import { DimSlicerTimeControl } from './DimSlicerTimeControl';

export type SelectionMode = 'scalar' | 'slice';
export type Axis = 'x' | 'y' | 'z' | 'c';

export interface SliceSelectionState {
mode: SelectionMode;
scalar: string;
start: string;
stop: string;
}

export function defaultSelection(dimSize?: number): SliceSelectionState {
const maxIndex = dimSize ? Math.max(dimSize - 1, 0) : 0;
return { mode: 'slice', scalar: '0', start: '0', stop: String(maxIndex) };
}

const MODE_ACCENT: Record<SelectionMode, string> = {
scalar: 'border-l-teal-700',
slice: 'border-l-[#644FF0]',
};
Comment on lines +25 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Tailwind CSS class border-l-black-700 is invalid because the color black does not have numeric shades (like 700) in Tailwind's default color palette. This will result in no border color being applied. Consider using a valid color like slate-700 or neutral-700.

Suggested change
const MODE_ACCENT: Record<SelectionMode, string> = {
scalar: 'border-l-black-700',
slice: 'border-l-pink-400',
};
const MODE_ACCENT: Record<SelectionMode, string> = {
scalar: 'border-l-slate-700',
slice: 'border-l-pink-400',
};


export interface DimSlicerProps {
/** Dimension name, e.g. "time" or "dim_0" */
dimName: string;
/** Size of this dimension */
dimSize: number;
/** Current selection state */
selection: SliceSelectionState;
/** Called whenever the selection changes */
onChange: (next: SliceSelectionState) => void;
/** Step size for the slider (optional, defaults to 1) */
step?: number;
/** Selected axis (optional, defaults to 'x') */
axis?: Axis;
/** Called when axis changes */
onAxisChange?: (axis: Axis) => void;
/** Array of actual values for this dimension (optional, if provided, dimSize should match values.length) */
values?: number[];
/** Function to format values for display (optional) */
formatValue?: (value: number) => string;
}

const DimSlicer: React.FC<DimSlicerProps> = ({
dimName,
dimSize,
selection,
onChange,
step = 1,
axis: propAxis = 'x',
onAxisChange,
values,
formatValue,
}) => {
const [currentAxis, setCurrentAxis] = useState<Axis>(propAxis);
const effectiveDimSize = values ? values.length : dimSize;
const sel = selection ?? defaultSelection(effectiveDimSize);

const getIndexFromValue = (val: number): number => {
if (!values) return val;
let closestIndex = 0;
let minDiff = Math.abs(values[0] - val);

for (let i = 1; i < values.length; i++) {
const diff = Math.abs(values[i] - val);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}

return closestIndex;
};

const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));

const parseOr = (v: string, fallback: number) => {
const n = parseInt(v, 10);
return Number.isNaN(n) ? fallback : n;
};

const changeScalarBy = (delta: number) => {
let val = parseOr(sel.scalar, 0) + delta;
val = clamp(val, 0, Math.max(effectiveDimSize - 1, 0));
onChange({ ...sel, scalar: String(val) });
};

const maxIndex = Math.max(effectiveDimSize - 1, 0);

const changeStartBy = (delta: number) => {
let val = parseOr(sel.start, 0) + delta;
val = clamp(val, 0, maxIndex);
onChange({ ...sel, start: String(val) });
};

const changeStopBy = (delta: number) => {
let val = parseOr(sel.stop, maxIndex) + delta;
val = clamp(val, 0, maxIndex);
onChange({ ...sel, stop: String(val) });
};

const updateSelection = (patch: Partial<SliceSelectionState>) => {
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);
Comment on lines +121 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For defensive programming, we should ensure that index is non-negative before accessing values[index]. If index is negative, it could result in an out-of-bounds access and potentially throw a runtime error when calling .toString().

Suggested change
const formattedValue = (index: number) =>
values && effectiveDimSize > 0 && index < values.length
? String(formatValue ? formatValue(values[index]) : values[index].toString())
: String(index);
const formattedValue = (index: number) =>
values && effectiveDimSize > 0 && index >= 0 && index < values.length
? String(formatValue ? formatValue(values[index]) : values[index].toString())
: String(index);


const isTimeDimension = dimName.toLowerCase().includes('time');
const isDateDimension = isTimeDimension || dimName.toLowerCase().includes('date');
const showTimeControls = Boolean(values && isTimeDimension);

return (
<div className={`border-0 border-l-2 rounded-md px-2 py-1.5 space-y-2 bg-muted/20 transition-colors ${MODE_ACCENT[sel.mode]}`}>

{/* Top row: dim name + mode toggle + axis toggle (always shown above slider) */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">{dimName}</span>
<div className="flex items-center gap-2">
<DimSlicerModeToggle mode={sel.mode} onModeChange={mode => updateSelection({ mode })} />
{sel.mode === 'slice' && (
<DimSlicerAxisToggle
axis={currentAxis}
onAxisChange={axis => {
setCurrentAxis(axis);
onAxisChange?.(axis);
}}
/>
)}
</div>
</div>

{/* Slider */}
{sel.mode === 'slice' && (
<div className="space-y-2 pb-0.5">
<Slider
min={0}
max={maxIndex}
step={step}
value={[startIndex, stopIndex]}
onValueChange={([newStart, newStop]) =>
updateSelection({ start: String(newStart), stop: String(newStop) })
}
className="w-full cursor-pointer"
/>
</div>
)}

{sel.mode === 'scalar' && (
<div className="space-y-2 pb-0.5">
<Slider
min={0}
max={Math.max(effectiveDimSize - 1, 0)}
step={step}
value={[scalarIndex]}
onValueChange={([val]) => updateSelection({ scalar: String(val) })}
className="w-full [&_[data-slot=slider-range]]:bg-transparent cursor-pointer"
/>
</div>
)}

{isDateDimension ? (
sel.mode === 'scalar' ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<DimSlicerTimeControl
layout="row"
showInput={false}
currentIndex={scalarIndex}
onIndexChange={(newScalar: number) => 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)}
/>
</div>
) : (
<div className="grid gap-2 lg:grid-cols-2">
<DimSlicerTimeControl
layout="row"
showInput={false}
currentIndex={startIndex}
onIndexChange={(newStart: number) => 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)}
/>

<div className="flex justify-end">
<DimSlicerTimeControl
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({ stop: String(getIndexFromValue(parsed)) });
}
}}
onIncrement={() => changeStopBy(+1)}
onDecrement={() => changeStopBy(-1)}
includeEnd
/>
</div>
</div>
)
) : (
<div className="flex items-center justify-between gap-2">
{sel.mode === 'slice' ? (
showTimeControls ? (
<DimSlicerTimeControl
currentIndex={startIndex}
onIndexChange={(newStart: number) => 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)}
/>
) : (
<DimSlicerNumericControl
value={startValue}
placeholder={formattedValue(0)}
onValueChange={value => {
const parsed = parseFloat(value);
if (!Number.isNaN(parsed)) {
updateSelection({ start: String(getIndexFromValue(parsed)) });
}
}}
onIncrement={() => changeStartBy(+1)}
onDecrement={() => changeStartBy(-1)}
ariaLabel="Start value"
showInput={!isDateDimension}
/>
)
) : (
<div className="w-16" />
)}

{sel.mode === 'slice' ? (
showTimeControls ? (
<DimSlicerTimeControl
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({ stop: String(getIndexFromValue(parsed)) });
}
}}
onIncrement={() => changeStopBy(+1)}
onDecrement={() => changeStopBy(-1)}
includeEnd
/>
) : (
<DimSlicerNumericControl
value={stopValue}
placeholder={formattedValue(Math.max(effectiveDimSize - 1, 0))}
onValueChange={value => {
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 ? (
<DimSlicerTimeControl
currentIndex={scalarIndex}
onIndexChange={(newScalar: number) => 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)}
/>
) : (
<DimSlicerNumericControl
value={scalarValue}
placeholder={formattedValue(0)}
onValueChange={value => {
const parsed = parseFloat(value);
if (!Number.isNaN(parsed)) {
updateSelection({ scalar: String(getIndexFromValue(parsed)) });
}
}}
onIncrement={() => changeScalarBy(+1)}
onDecrement={() => changeScalarBy(-1)}
ariaLabel="Scalar value"
showInput={!isDateDimension}
/>
)}
</div>
)}
</div>
);
};

export { DimSlicer };
export default DimSlicer;
Loading
Loading