ui(components): Adds InputNumber component and 4d panel updates (#4874)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukasandsedghi authored and GitHub committed 2025-04-01 09:30:32 -04:00
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;
}
+6 -1
View File
@@ -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 -2
View File
@@ -1,6 +1,5 @@
import * as utils from './utils';
export * from './components';
export * from './contextProviders';
import * as utils from './utils';
export { utils };