ui(components): Adds InputNumber component and 4d panel updates (#4874)
Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
1 parent
c4045251fb
commit
b7419fb6a1
15 files changed
+1620
-304
No files matched your search
@@ -0,0 +1,405 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Button } from '../Button/Button';
|
||||
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Calculate decimal places based on step
|
||||
const getDecimalPlaces = (step: number): number => {
|
||||
if (Number.isInteger(step)) {
|
||||
return 0;
|
||||
}
|
||||
const stepStr = step.toString();
|
||||
if (stepStr.includes('.')) {
|
||||
return stepStr.split('.')[1].length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Enhanced context type - includes shared constraints
|
||||
type InputNumberContextType = {
|
||||
value: number | string;
|
||||
setValue: React.Dispatch<React.SetStateAction<number | string>>;
|
||||
onChange: (value: number) => void;
|
||||
// Shared constraints that can be used by subcomponents
|
||||
constraints?: {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
// Create context
|
||||
const InputNumberContext = React.createContext<InputNumberContextType | undefined>(undefined);
|
||||
|
||||
// Hook to use context
|
||||
const useInputNumber = () => {
|
||||
const context = React.useContext(InputNumberContext);
|
||||
if (!context) {
|
||||
throw new Error('useInputNumber must be used within an <InputNumber> component');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Simplified root component props
|
||||
export interface InputNumberProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// The single top-level InputNumber component - much simpler now
|
||||
const InputNumber = React.forwardRef<HTMLDivElement, InputNumberProps>(
|
||||
({ value, onChange, className, children, ...props }, ref) => {
|
||||
const [inputValue, setInputValue] = React.useState<number | string>(value);
|
||||
|
||||
// Update internal state when prop changes
|
||||
React.useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Context value - only core state
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
value: inputValue,
|
||||
setValue: setInputValue,
|
||||
onChange,
|
||||
}),
|
||||
[inputValue, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<InputNumberContext.Provider value={contextValue}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</InputNumberContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputNumber.displayName = 'InputNumber';
|
||||
|
||||
// Input component with its own constraints
|
||||
export interface InputNumberInputProps
|
||||
extends Omit<React.ComponentPropsWithoutRef<typeof Input>, 'onChange'> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
const InputNumberInput = React.forwardRef<HTMLInputElement, InputNumberInputProps>(
|
||||
({ className, min, max, step, ...props }, ref) => {
|
||||
// Get context which may include constraints from parent
|
||||
const { value, setValue, onChange, constraints = {} } = useInputNumber();
|
||||
|
||||
// Use provided props or fall back to parent constraints or defaults
|
||||
const effectiveMin = min ?? constraints.min ?? 0;
|
||||
const effectiveMax = max ?? constraints.max ?? 100;
|
||||
const effectiveStep = step ?? constraints.step ?? 1;
|
||||
const decimalPlaces = getDecimalPlaces(effectiveStep);
|
||||
|
||||
// Format displayed value with proper decimal places
|
||||
const displayValue = React.useMemo(() => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return decimalPlaces > 0 ? value.toFixed(decimalPlaces) : value.toString();
|
||||
}, [value, decimalPlaces]);
|
||||
|
||||
// Handle input change with constraint awareness
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
|
||||
// Allow empty string, minus sign, or decimal point for flexibility
|
||||
if (val === '' || val === '-' || val === '.') {
|
||||
setValue(val);
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = Number(val);
|
||||
if (!isNaN(numValue)) {
|
||||
setValue(numValue);
|
||||
// Only call onChange if value is within boundaries
|
||||
if (numValue >= effectiveMin && numValue <= effectiveMax) {
|
||||
onChange(numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[effectiveMin, effectiveMax, onChange, setValue]
|
||||
);
|
||||
|
||||
// Handle blur to format and validate
|
||||
const handleBlur = React.useCallback(() => {
|
||||
if (typeof value === 'string') {
|
||||
// Handle empty or partial inputs
|
||||
if (value === '' || value === '-' || value === '.') {
|
||||
setValue(effectiveMin);
|
||||
onChange(effectiveMin);
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = parseFloat(value);
|
||||
if (isNaN(numValue)) {
|
||||
setValue(effectiveMin);
|
||||
onChange(effectiveMin);
|
||||
return;
|
||||
}
|
||||
|
||||
// Constrain value to min/max
|
||||
const boundedValue = Math.max(effectiveMin, Math.min(effectiveMax, numValue));
|
||||
setValue(boundedValue);
|
||||
onChange(boundedValue);
|
||||
}
|
||||
}, [value, effectiveMin, effectiveMax, onChange, setValue]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
'h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputNumberInput.displayName = 'InputNumber.Input';
|
||||
|
||||
// Label component with position prop
|
||||
export interface InputNumberLabelProps extends React.HTMLAttributes<HTMLLabelElement> {
|
||||
position?: 'left' | 'right' | 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const InputNumberLabel = React.forwardRef<HTMLLabelElement, InputNumberLabelProps>(
|
||||
({ className, position = 'left', children, ...props }, ref) => {
|
||||
const positionClasses = {
|
||||
left: 'mr-2',
|
||||
right: 'ml-2',
|
||||
top: 'mb-1',
|
||||
bottom: 'mt-1',
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn('text-muted-foreground text-xs', positionClasses[position], className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputNumberLabel.displayName = 'InputNumber.Label';
|
||||
|
||||
// Container for horizontal button layout with constraints that are shared via context
|
||||
export interface InputNumberHorizontalControlsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const InputNumberHorizontalControls = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
InputNumberHorizontalControlsProps
|
||||
>(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => {
|
||||
// Get existing context and enhance it with constraints
|
||||
const context = useInputNumber();
|
||||
const { value, onChange } = context;
|
||||
|
||||
// Set constraints in context for child components to use
|
||||
React.useEffect(() => {
|
||||
context.constraints = { min, max, step, disabled };
|
||||
}, [context, min, max, step, disabled]);
|
||||
|
||||
// Increment function with local constraints
|
||||
const increment = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = typeof value === 'string' ? parseFloat(value) || min : value;
|
||||
const newValue = Math.min(currentValue + step, max);
|
||||
onChange(newValue);
|
||||
}, [value, min, max, step, onChange, disabled]);
|
||||
|
||||
// Decrement function with local constraints
|
||||
const decrement = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = typeof value === 'string' ? parseFloat(value) || min : value;
|
||||
const newValue = Math.max(currentValue - step, min);
|
||||
onChange(newValue);
|
||||
}, [value, min, max, step, onChange, disabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-row items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={decrement}
|
||||
disabled={disabled}
|
||||
className="text-primary h-6 w-4 cursor-pointer p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={increment}
|
||||
disabled={disabled}
|
||||
className="text-primary h-6 w-4 cursor-pointer p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InputNumberHorizontalControls.displayName = 'InputNumber.HorizontalControls';
|
||||
|
||||
// Vertical controls with its own constraints
|
||||
export interface InputNumberVerticalControlsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const InputNumberVerticalControls = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
InputNumberVerticalControlsProps
|
||||
>(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => {
|
||||
// Get existing context and enhance it with constraints
|
||||
const context = useInputNumber();
|
||||
const { value, onChange } = context;
|
||||
|
||||
// Set constraints in context for child components to use
|
||||
React.useEffect(() => {
|
||||
context.constraints = { min, max, step, disabled };
|
||||
}, [context, min, max, step, disabled]);
|
||||
|
||||
// Increment function with local constraints
|
||||
const increment = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = typeof value === 'string' ? parseFloat(value) || min : value;
|
||||
const newValue = Math.min(currentValue + step, max);
|
||||
onChange(newValue);
|
||||
}, [value, min, max, step, onChange, disabled]);
|
||||
|
||||
// Decrement function with local constraints
|
||||
const decrement = React.useCallback(() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentValue = typeof value === 'string' ? parseFloat(value) || min : value;
|
||||
const newValue = Math.max(currentValue - step, min);
|
||||
onChange(newValue);
|
||||
}, [value, min, max, step, onChange, disabled]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="ml-1 flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={increment}
|
||||
disabled={disabled}
|
||||
className="text-primary h-3 w-5 pr-px"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={decrement}
|
||||
disabled={disabled}
|
||||
className="text-primary h-3 w-5 pr-px"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InputNumberVerticalControls.displayName = 'InputNumber.VerticalControls';
|
||||
|
||||
// Container with size prop
|
||||
const sizesClasses = {
|
||||
sm: 'w-[45px] h-[28px]',
|
||||
md: 'w-[58px] h-[28px]',
|
||||
lg: 'w-[206px] h-[35px]',
|
||||
};
|
||||
|
||||
export interface InputNumberContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
sizeClassName?: string;
|
||||
}
|
||||
|
||||
const InputNumberContainer = React.forwardRef<HTMLDivElement, InputNumberContainerProps>(
|
||||
({ className, size = 'md', sizeClassName, children, ...props }, ref) => {
|
||||
const sizeToUse = sizeClassName || sizesClasses[size];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-background border-input flex items-center rounded-md border',
|
||||
sizeToUse,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
InputNumberContainer.displayName = 'InputNumber.Container';
|
||||
|
||||
// Attach subcomponents
|
||||
Object.assign(InputNumber, {
|
||||
Input: InputNumberInput,
|
||||
Label: InputNumberLabel,
|
||||
Container: InputNumberContainer,
|
||||
HorizontalControls: InputNumberHorizontalControls,
|
||||
VerticalControls: InputNumberVerticalControls,
|
||||
});
|
||||
|
||||
export { InputNumber };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InputNumber } from './InputNumber';
|
||||
export { InputNumber };
|
||||
export type { InputNumberProps } from './InputNumber';
|
||||
@@ -5,9 +5,23 @@ import { cn } from '../../lib/utils';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Slider } from '../Slider/Slider';
|
||||
import { DoubleSlider } from '../DoubleSlider/DoubleSlider';
|
||||
import { Button } from '../Button/Button';
|
||||
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
// Calculate decimal places based on step
|
||||
const getDecimalPlaces = (step: number): number => {
|
||||
if (Number.isInteger(step)) {
|
||||
return 0;
|
||||
}
|
||||
const stepStr = step.toString();
|
||||
if (stepStr.includes('.')) {
|
||||
return stepStr.split('.')[1].length;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
interface NumericMetaContextValue {
|
||||
mode: 'number' | 'singleRange' | 'doubleRange';
|
||||
mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper';
|
||||
singleValue: number;
|
||||
doubleValue: [number, number];
|
||||
setSingleValue: (val: number) => void;
|
||||
@@ -23,7 +37,7 @@ const NumericMetaContext = createContext<NumericMetaContextValue | null>(null);
|
||||
1) Container
|
||||
---------------------------------------------------------------------------*/
|
||||
interface NumericMetaContainerProps {
|
||||
mode: 'number' | 'singleRange' | 'doubleRange';
|
||||
mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper';
|
||||
value?: number; // for controlled single-value usage from parent
|
||||
defaultValue?: number; // for uncontrolled single-value usage
|
||||
values?: [number, number]; // for controlled double-range usage from parent
|
||||
@@ -57,10 +71,10 @@ function NumericMetaContainer({
|
||||
|
||||
// Use useControllableState for both single and double values
|
||||
const [internalSingleValue, setInternalSingleValue] = useControllableState({
|
||||
prop: mode === 'number' || mode === 'singleRange' ? value : undefined,
|
||||
prop: mode === 'number' || mode === 'singleRange' || mode === 'stepper' ? value : undefined,
|
||||
defaultProp: calculatedDefaultValue,
|
||||
onChange: newVal => {
|
||||
if (mode === 'number' || mode === 'singleRange') {
|
||||
if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') {
|
||||
onChange?.(newVal);
|
||||
}
|
||||
},
|
||||
@@ -127,7 +141,7 @@ function NumericMetaLabel({ children, showValue, className }: NumericMetaLabelPr
|
||||
|
||||
let displayedValue = '';
|
||||
let valueClasses = '';
|
||||
if (mode === 'number' || mode === 'singleRange') {
|
||||
if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') {
|
||||
displayedValue = singleValue.toString();
|
||||
valueClasses = 'w-10';
|
||||
} else if (mode === 'doubleRange') {
|
||||
@@ -291,12 +305,177 @@ function NumberInput({ className }: NumberInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
6) NumberStepper sub-component
|
||||
---------------------------------------------------------------------------*/
|
||||
interface NumberStepperProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
// Modified NumberStepper component to properly position left/right controls
|
||||
function NumberStepper({ className, children, direction }: NumberStepperProps) {
|
||||
const ctx = useContext(NumericMetaContext);
|
||||
if (!ctx) {
|
||||
throw new Error('NumberStepper must be used inside <Numeric.Container>.');
|
||||
}
|
||||
|
||||
const { mode, singleValue, setSingleValue, min, max, step } = ctx;
|
||||
if (mode !== 'stepper') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate decimal places based on step
|
||||
const decimalPlaces = getDecimalPlaces(step);
|
||||
|
||||
// Format displayed value with proper decimal places
|
||||
const displayValue = React.useMemo(() => {
|
||||
return decimalPlaces > 0 ? singleValue.toFixed(decimalPlaces) : singleValue.toString();
|
||||
}, [singleValue, decimalPlaces]);
|
||||
|
||||
const handleInputChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = evt.target.value;
|
||||
|
||||
// Allow empty string, minus sign, or decimal point for flexibility
|
||||
if (val === '' || val === '-' || val === '.') {
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = Number(val);
|
||||
if (!isNaN(numValue)) {
|
||||
setSingleValue(Math.max(min, Math.min(numValue, max)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
// Ensure value is within constraints when input loses focus
|
||||
const boundedValue = Math.max(min, Math.min(singleValue, max));
|
||||
if (boundedValue !== singleValue) {
|
||||
setSingleValue(boundedValue);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if children is HorizontalControls component
|
||||
const hasHorizontalControls = direction === 'horizontal';
|
||||
|
||||
if (hasHorizontalControls) {
|
||||
// We'll handle the control positioning ourselves
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background border-input flex h-[28px] items-center overflow-hidden rounded-md border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<LeftControl
|
||||
min={min}
|
||||
step={step}
|
||||
value={singleValue}
|
||||
setValue={setSingleValue}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
className="h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none"
|
||||
/>
|
||||
{children}
|
||||
<RightControl
|
||||
max={max}
|
||||
step={step}
|
||||
value={singleValue}
|
||||
setValue={setSingleValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-background border-input flex items-center overflow-hidden rounded-md border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleBlur}
|
||||
className={cn(
|
||||
'h-6 flex-1 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none'
|
||||
)}
|
||||
/>
|
||||
<div className="ml-1 flex flex-col">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSingleValue(singleValue + step)}
|
||||
disabled={singleValue >= max}
|
||||
className="text-primary h-3 w-5 pr-px"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSingleValue(singleValue - step)}
|
||||
disabled={singleValue <= min}
|
||||
className="text-primary h-3 w-5 pr-px"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// New components for left and right controls
|
||||
function LeftControl({ min, step, value, setValue }) {
|
||||
const decrement = useCallback(() => {
|
||||
const newValue = Math.max(value - step, min);
|
||||
setValue(newValue);
|
||||
}, [value, min, step, setValue]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={decrement}
|
||||
className="text-primary h-full w-4 cursor-pointer p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function RightControl({ max, step, value, setValue }) {
|
||||
const increment = useCallback(() => {
|
||||
const newValue = Math.min(value + step, max);
|
||||
setValue(newValue);
|
||||
}, [value, max, step, setValue]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={increment}
|
||||
className="text-primary h-full w-4 cursor-pointer p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export const Numeric = {
|
||||
Container: NumericMetaContainer,
|
||||
Label: NumericMetaLabel,
|
||||
SingleRange,
|
||||
DoubleRange,
|
||||
NumberInput,
|
||||
NumberStepper,
|
||||
};
|
||||
|
||||
export default Numeric;
|
||||
export default Numeric;
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Numeric } from '../Numeric/Numeric';
|
||||
import WindowLevelHistogram from './WindowLevelHistogram';
|
||||
import { VOI, VOIRange, Histogram, Colormap } from './types';
|
||||
|
||||
interface WindowLevelProps {
|
||||
title?: string;
|
||||
step?: number;
|
||||
histogram: Histogram;
|
||||
voi: VOI;
|
||||
opacity?: number;
|
||||
showOpacitySlider?: boolean;
|
||||
colormap?: Colormap;
|
||||
style?: 'polygon' | 'bars';
|
||||
fillColor?: string;
|
||||
lineColor?: string;
|
||||
containerClassName?: string;
|
||||
onVOIChange?: (voi: VOI) => void;
|
||||
onOpacityChange?: (opacity: number) => void;
|
||||
}
|
||||
|
||||
const convertVOItoVOIRange = (voi: VOI): VOIRange => {
|
||||
return {
|
||||
min: voi.windowCenter - voi.windowWidth / 2,
|
||||
max: voi.windowCenter + voi.windowWidth / 2,
|
||||
};
|
||||
};
|
||||
|
||||
const WindowLevel = ({
|
||||
title,
|
||||
step = 1,
|
||||
histogram,
|
||||
voi: voiProp,
|
||||
opacity: opacityProp = 1,
|
||||
showOpacitySlider = false,
|
||||
colormap,
|
||||
style = 'polygon',
|
||||
fillColor = '#3f3f3f',
|
||||
lineColor = '#707070',
|
||||
containerClassName,
|
||||
onVOIChange,
|
||||
onOpacityChange,
|
||||
}: WindowLevelProps) => {
|
||||
// Define range from histogram
|
||||
const range = useMemo(
|
||||
() => ({
|
||||
min: histogram.range.min,
|
||||
max: histogram.range.max,
|
||||
}),
|
||||
[histogram]
|
||||
);
|
||||
|
||||
// Initialize VOI range from props
|
||||
const [voiRange, setVOIRange] = useState<VOIRange>(
|
||||
voiProp
|
||||
? convertVOItoVOIRange(voiProp)
|
||||
: {
|
||||
min: range.min,
|
||||
max: range.max,
|
||||
}
|
||||
);
|
||||
|
||||
// Update VOI range when props change
|
||||
useEffect(() => {
|
||||
if (voiProp) {
|
||||
setVOIRange(convertVOItoVOIRange(voiProp));
|
||||
}
|
||||
}, [voiProp]);
|
||||
|
||||
// Handle VOI range changes (from slider)
|
||||
const handleVOIRangeChange = (newValues: [number, number]) => {
|
||||
const [min, max] = newValues;
|
||||
if (min === voiRange.min && max === voiRange.max) {
|
||||
return;
|
||||
}
|
||||
|
||||
const windowWidth = max - min;
|
||||
const windowCenter = min + windowWidth / 2;
|
||||
|
||||
setVOIRange({
|
||||
min,
|
||||
max,
|
||||
});
|
||||
|
||||
if (onVOIChange) {
|
||||
onVOIChange({ windowWidth, windowCenter });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle opacity changes
|
||||
const handleOpacityChange = (value: number) => {
|
||||
if (onOpacityChange) {
|
||||
onOpacityChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`text-foreground max-w-sm p-1 ${containerClassName || ''}`}>
|
||||
{title && <div className="text-muted-foreground mb-1 text-sm">{title}</div>}
|
||||
|
||||
<div className="space-y-2 px-2">
|
||||
{/* Range Labels */}
|
||||
<div className="text-muted-foreground flex h-4 text-sm">
|
||||
<div className="relative h-fit grow">
|
||||
<span className="absolute left-0 bottom-px leading-3">{range.min}</span>
|
||||
</div>
|
||||
<div className="relative h-fit grow text-right">
|
||||
<span className="absolute right-0 bottom-px leading-3">{range.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Histogram Visualization */}
|
||||
<div className="bg-foreground h-11">
|
||||
<WindowLevelHistogram
|
||||
range={range}
|
||||
voiRange={voiRange}
|
||||
histogram={histogram}
|
||||
colormap={colormap}
|
||||
style={style}
|
||||
fillColor={fillColor}
|
||||
lineColor={lineColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Window Level Range Slider */}
|
||||
<Numeric.Container
|
||||
mode="doubleRange"
|
||||
min={range.min}
|
||||
max={range.max}
|
||||
values={[voiRange.min, voiRange.max]}
|
||||
step={step}
|
||||
onChange={values => handleVOIRangeChange(values as [number, number])}
|
||||
>
|
||||
<Numeric.DoubleRange showNumberInputs />
|
||||
</Numeric.Container>
|
||||
|
||||
{/* Opacity Slider (optional) */}
|
||||
{showOpacitySlider && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between gap-2 text-base">
|
||||
<div className="text-xs text-white">Opacity</div>
|
||||
<div className="flex-1">
|
||||
<Numeric.Container
|
||||
mode="singleRange"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={opacityProp}
|
||||
onChange={value => handleOpacityChange(value as number)}
|
||||
>
|
||||
<Numeric.SingleRange showNumberInput />
|
||||
</Numeric.Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WindowLevel;
|
||||
@@ -0,0 +1,237 @@
|
||||
import React, { useLayoutEffect, useEffect, useRef } from 'react';
|
||||
import { Range, VOIRange, Histogram, Colormap } from './types';
|
||||
|
||||
const DEFAULT_COLORMAP = {
|
||||
Name: 'Grayscale',
|
||||
RGBPoints: [0, 0, 0, 0, 1, 1, 1, 1],
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(min, value), max);
|
||||
|
||||
const interpolateVec3 = (a: number[], b: number[], t: number) => {
|
||||
return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t, a[2] * (1 - t) + b[2] * t];
|
||||
};
|
||||
|
||||
const drawBackground = (canvas: HTMLCanvasElement, range: Range, voiRange: VOIRange, colormap: Colormap) => {
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const { width, height } = canvas;
|
||||
const windowWidth = voiRange.max - voiRange.min;
|
||||
const rgbPoints = colormap.RGBPoints;
|
||||
const colorsCount = rgbPoints.length / 4;
|
||||
|
||||
const getColorPoint = (index: number) => {
|
||||
const offset = 4 * index;
|
||||
|
||||
return index < colorsCount
|
||||
? {
|
||||
index,
|
||||
position: rgbPoints[offset],
|
||||
color: [rgbPoints[offset + 1], rgbPoints[offset + 2], rgbPoints[offset + 3]],
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
let leftColorPoint = undefined;
|
||||
let rightColorPoint = getColorPoint(0);
|
||||
|
||||
const incRawPixelValue = (range.max - range.min) / (width - 1);
|
||||
let rawPixelValue = range.min;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const tVoiRange = (rawPixelValue - voiRange.min) / windowWidth;
|
||||
|
||||
// Find the color in a linear way (O(n) complexity)
|
||||
if (rightColorPoint) {
|
||||
for (let i = rightColorPoint.index; i < colorsCount; i++) {
|
||||
if (tVoiRange <= rightColorPoint.position) {
|
||||
break;
|
||||
}
|
||||
|
||||
leftColorPoint = rightColorPoint;
|
||||
rightColorPoint = getColorPoint(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let normColor;
|
||||
|
||||
if (!leftColorPoint) {
|
||||
normColor = [...rightColorPoint!.color];
|
||||
} else if (!rightColorPoint) {
|
||||
normColor = [...leftColorPoint.color];
|
||||
} else {
|
||||
const tColorRange =
|
||||
(tVoiRange - leftColorPoint.position) /
|
||||
(rightColorPoint.position - leftColorPoint.position);
|
||||
|
||||
normColor = interpolateVec3(leftColorPoint.color, rightColorPoint.color, tColorRange);
|
||||
}
|
||||
|
||||
const color = normColor.map(color => clamp(Math.round(color * 255), 0, 255));
|
||||
|
||||
context.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
||||
context.fillRect(x, 0, 1, height);
|
||||
|
||||
rawPixelValue += incRawPixelValue;
|
||||
}
|
||||
};
|
||||
|
||||
/* plots the histogram bins as a polygon that traces the centers of each bin */
|
||||
const drawPolygonHistogram = (
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
histogram: Histogram,
|
||||
options: { scale: (val: number) => number, fillColor: string, lineColor: string }
|
||||
) => {
|
||||
const xScale = canvas.width / histogram.numBins;
|
||||
const { scale } = options;
|
||||
const maxVal = scale(histogram.maxBinValue);
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(0, canvas.height);
|
||||
context.lineTo(0, canvas.height - (canvas.height * scale(histogram.bins[0])) / maxVal);
|
||||
|
||||
let x = xScale / 2;
|
||||
for (let bin = 0; bin < histogram.numBins; bin++) {
|
||||
context.lineTo(x, canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal);
|
||||
x += xScale;
|
||||
}
|
||||
context.lineTo(
|
||||
canvas.width,
|
||||
canvas.height - (canvas.height * scale(histogram.bins[histogram.numBins - 1])) / maxVal
|
||||
);
|
||||
context.lineTo(canvas.width, canvas.height);
|
||||
context.lineTo(0, canvas.height);
|
||||
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
/* plots the histogram bins as a series of n vertical bars (n = number of bins) */
|
||||
const drawBarHistogram = (
|
||||
canvas: HTMLCanvasElement,
|
||||
context: CanvasRenderingContext2D,
|
||||
histogram: Histogram,
|
||||
options: { scale: (val: number) => number, fillColor: string, lineColor: string }
|
||||
) => {
|
||||
const xScale = canvas.width / histogram.numBins;
|
||||
const { scale } = options;
|
||||
const maxVal = scale(histogram.maxBinValue);
|
||||
|
||||
context.beginPath();
|
||||
|
||||
for (let bin = 0; bin < histogram.numBins; bin++) {
|
||||
context.moveTo(xScale * bin, canvas.height);
|
||||
context.lineTo(
|
||||
xScale * bin,
|
||||
canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal
|
||||
);
|
||||
}
|
||||
|
||||
context.closePath();
|
||||
context.strokeStyle = options.fillColor;
|
||||
context.lineWidth = xScale;
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
const drawHistogram = (
|
||||
canvas: HTMLCanvasElement,
|
||||
histogram: Histogram,
|
||||
options: {
|
||||
style: 'polygon' | 'bars',
|
||||
fillColor: string,
|
||||
lineColor: string,
|
||||
scale: (val: number) => number
|
||||
}
|
||||
) => {
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
context.fillStyle = options.fillColor;
|
||||
context.strokeStyle = options.lineColor;
|
||||
|
||||
if (options.style === 'polygon') {
|
||||
drawPolygonHistogram(canvas, context, histogram, options);
|
||||
} else if (options.style === 'bars') {
|
||||
drawBarHistogram(canvas, context, histogram, options);
|
||||
} else {
|
||||
throw new Error(`Invalid style (${options.style})`);
|
||||
}
|
||||
};
|
||||
|
||||
interface WindowLevelHistogramProps {
|
||||
range: Range;
|
||||
voiRange: VOIRange;
|
||||
histogram: Histogram;
|
||||
colormap?: Colormap;
|
||||
style?: 'polygon' | 'bars';
|
||||
fillColor?: string;
|
||||
lineColor?: string;
|
||||
}
|
||||
|
||||
const WindowLevelHistogram = ({
|
||||
range,
|
||||
voiRange,
|
||||
histogram,
|
||||
colormap = DEFAULT_COLORMAP,
|
||||
style = 'polygon',
|
||||
fillColor = '#3f3f3f',
|
||||
lineColor = '#707070',
|
||||
}: WindowLevelHistogramProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const minVOIPercent = ((voiRange.min - range.min) / (range.max - range.min)) * 100;
|
||||
const maxVoiPercent = (1 - (range.max - voiRange.max) / (range.max - range.min)) * 100;
|
||||
const background =
|
||||
colormap.Name !== 'Grayscale'
|
||||
? undefined
|
||||
: `linear-gradient(to right, #000 0%, #000 ${minVOIPercent}%, #fff ${maxVoiPercent}%, #fff 100%)`;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
canvas.width = container.offsetWidth;
|
||||
canvas.height = container.offsetHeight;
|
||||
}, [containerRef, canvasRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const fnIdentity = (x: number) => x;
|
||||
const options = {
|
||||
style,
|
||||
fillColor,
|
||||
lineColor,
|
||||
scale: fnIdentity,
|
||||
};
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
drawBackground(canvas, range, voiRange, colormap);
|
||||
drawHistogram(canvas, histogram, options);
|
||||
}, [range, voiRange, histogram, colormap, style, fillColor, lineColor]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full"
|
||||
style={{ background }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width="1"
|
||||
height="1"
|
||||
></canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WindowLevelHistogram;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as WindowLevel } from './WindowLevel';
|
||||
export { default as WindowLevelHistogram } from './WindowLevelHistogram';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,41 @@
|
||||
// Window-level related types
|
||||
|
||||
export interface Range {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface VOIRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface VOI {
|
||||
windowWidth: number;
|
||||
windowCenter: number;
|
||||
}
|
||||
|
||||
export interface Histogram {
|
||||
bins: number[] | Int32Array;
|
||||
numBins: number;
|
||||
maxBin: number;
|
||||
maxBinValue: number;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
export interface Colormap {
|
||||
Name: string;
|
||||
RGBPoints: number[];
|
||||
}
|
||||
|
||||
export interface WindowLevelData {
|
||||
volumeId: string;
|
||||
viewportId: string;
|
||||
modality: string;
|
||||
voi: VOI;
|
||||
step: number;
|
||||
histogram: Histogram;
|
||||
showOpacitySlider: boolean;
|
||||
opacity?: number;
|
||||
colormap?: Colormap;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs';
|
||||
import { Toggle, toggleVariants } from './Toggle';
|
||||
import { ToggleGroup, ToggleGroupItem } from './ToggleGroup';
|
||||
import { Input } from './Input';
|
||||
import { InputNumber } from './InputNumber';
|
||||
import { Label } from './Label';
|
||||
import { Switch } from './Switch';
|
||||
import { Checkbox } from './Checkbox';
|
||||
@@ -59,6 +60,7 @@ import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals';
|
||||
import Modal from './Modal/Modal';
|
||||
import { FooterAction } from './FooterAction';
|
||||
import { InputFilter } from './InputFilter';
|
||||
import { WindowLevel, WindowLevelHistogram } from './OHIFPanels';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -154,11 +156,12 @@ export {
|
||||
Calendar,
|
||||
DatePickerWithRange,
|
||||
Input,
|
||||
InputNumber,
|
||||
Label,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
Separator,
|
||||
Switch,
|
||||
Checkbox,
|
||||
@@ -245,4 +248,6 @@ export {
|
||||
FooterAction,
|
||||
ToolSettings,
|
||||
InputFilter,
|
||||
WindowLevel,
|
||||
WindowLevelHistogram,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as utils from './utils';
|
||||
export * from './components';
|
||||
export * from './contextProviders';
|
||||
|
||||
import * as utils from './utils';
|
||||
|
||||
export { utils };
|
||||
Reference in new issue
Block a user