refactor(ui): Migrate components to ui-next (#4930)
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
1 parent
1b8729dded
commit
2bdf4afa4d
80 files changed
+2196
-521
No files matched your search
@@ -40,8 +40,11 @@ export interface ButtonProps
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, forwardRef) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const dataCY = props.dataCY || `${props.name}-btn`;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-cy={dataCY}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
ref={forwardRef}
|
||||
{...props}
|
||||
|
||||
@@ -13,7 +13,7 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
'bg-background text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -40,10 +40,10 @@ const CommandInput = React.forwardRef<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className="flex items-center border-b px-3"
|
||||
className="border-input flex items-center border-b px-3"
|
||||
cmdk-input-wrapper=""
|
||||
>
|
||||
<MagnifyingGlassIcon className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<MagnifyingGlassIcon className="text-primary mr-2 h-4 w-4 shrink-0" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
@@ -76,7 +76,7 @@ const CommandEmpty = React.forwardRef<
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-base"
|
||||
className="py-5 text-center text-base"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -105,7 +105,7 @@ const CommandSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
className={cn('bg-input -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -118,7 +118,7 @@ const CommandItem = React.forwardRef<
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
'aria-selected:bg-primary/30 aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-base outline-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -65,7 +65,7 @@ function Header({
|
||||
onClick={onClickReturn}
|
||||
data-cy="return-to-work-list"
|
||||
>
|
||||
{isReturnEnabled && <Icons.ArrowLeft className="text-primary w-8" />}
|
||||
{isReturnEnabled && <Icons.ArrowLeft className="text-primary ml-1 h-7 w-7" />}
|
||||
<div className="ml-1">
|
||||
{WhiteLabeling?.createLogoComponentFn?.(React, props) || <Icons.OHIFLogo />}
|
||||
</div>
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icons } from '@ohif/ui-next';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export enum showDialogOption {
|
||||
NeverShowDialog = 'never',
|
||||
AlwaysShowDialog = 'always',
|
||||
ShowOnceAndConfigure = 'configure',
|
||||
}
|
||||
|
||||
const InvestigationalUseDialog = ({
|
||||
dialogConfiguration = {
|
||||
option: showDialogOption.AlwaysShowDialog,
|
||||
},
|
||||
}) => {
|
||||
const { option, days } = dialogConfiguration;
|
||||
const [isHidden, setIsHidden] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const dialogLocalState = localStorage.getItem('investigationalUseDialog');
|
||||
const dialogSessionState = sessionStorage.getItem('investigationalUseDialog');
|
||||
|
||||
switch (option) {
|
||||
case showDialogOption.NeverShowDialog:
|
||||
setIsHidden(true);
|
||||
break;
|
||||
case showDialogOption.AlwaysShowDialog:
|
||||
setIsHidden(!!dialogSessionState);
|
||||
break;
|
||||
case showDialogOption.ShowOnceAndConfigure:
|
||||
if (dialogLocalState) {
|
||||
const { expiryDate } = JSON.parse(dialogLocalState);
|
||||
const isExpired = new Date() > new Date(expiryDate);
|
||||
setIsHidden(!isExpired);
|
||||
} else {
|
||||
setIsHidden(false);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
setIsHidden(true);
|
||||
}
|
||||
}, [option, days]);
|
||||
|
||||
const handleConfirmAndHide = () => {
|
||||
const expiryDate = new Date();
|
||||
|
||||
switch (option) {
|
||||
case showDialogOption.ShowOnceAndConfigure:
|
||||
expiryDate.setDate(expiryDate.getDate() + days);
|
||||
localStorage.setItem('investigationalUseDialog', JSON.stringify({ expiryDate }));
|
||||
break;
|
||||
case showDialogOption.AlwaysShowDialog:
|
||||
sessionStorage.setItem('investigationalUseDialog', 'hidden');
|
||||
break;
|
||||
}
|
||||
setIsHidden(true);
|
||||
};
|
||||
|
||||
if (isHidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-2 z-50 flex h-[86px] w-full justify-center">
|
||||
<div className="bg-secondary-dark border-primary-dark flex w-[90%] items-center justify-between rounded-lg border-2 pl-[22px] pr-[22px] pt-[10px] pb-[10px] shadow-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icons.InvestigationalUse className="h-18 w-18" />
|
||||
<div className="flex flex-col">
|
||||
<div className="text-[19px] text-white">
|
||||
OHIF Viewer is{' '}
|
||||
<span className="text-primary-light">for investigational use only</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-white">
|
||||
<span
|
||||
className="text-primary-active cursor-pointer"
|
||||
onClick={() => window.open('https://ohif.org/', '_blank')}
|
||||
>
|
||||
Learn more about OHIF Viewer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleConfirmAndHide}
|
||||
className="bg-primary-main"
|
||||
dataCY="confirm-and-hide-button"
|
||||
>
|
||||
Confirm and Hide
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
InvestigationalUseDialog.propTypes = {
|
||||
dialogConfiguration: PropTypes.shape({
|
||||
option: PropTypes.oneOf(Object.values(showDialogOption)).isRequired,
|
||||
days: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
export default InvestigationalUseDialog;
|
||||
@@ -0,0 +1,3 @@
|
||||
import InvestigationalUseDialog from './InvestigationalUseDialog';
|
||||
|
||||
export default InvestigationalUseDialog;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '../Command/Command'; // Assuming Command is in ../Command relative path
|
||||
import { cn } from '../../lib/utils'; // Assuming cn utility is here
|
||||
import { ScrollArea } from '../ScrollArea/ScrollArea'; // Import ScrollArea
|
||||
|
||||
interface PropType {
|
||||
labellingDoneCallback: (label: string) => void;
|
||||
measurementData: any; // Keep any for now as per instructions
|
||||
labelData: LabelInfo[]; // Use LabelInfo[] for better type safety
|
||||
exclusive: boolean;
|
||||
hide: () => void; // Add hide prop explicitly
|
||||
placeholder?: string; // Optional placeholder for input
|
||||
emptyMessage?: string; // Optional message for empty results
|
||||
initialLabel?: string; // Optional initial selected label
|
||||
}
|
||||
|
||||
export interface LabelInfo {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Convert to Functional Component
|
||||
const LabellingFlow: React.FC<PropType> = ({
|
||||
labellingDoneCallback,
|
||||
measurementData,
|
||||
labelData,
|
||||
exclusive,
|
||||
hide,
|
||||
placeholder = 'Type label or search...',
|
||||
emptyMessage = 'No labels found.',
|
||||
initialLabel,
|
||||
}) => {
|
||||
// State for the selected value
|
||||
const [value, setValue] = useState<string | undefined>(initialLabel);
|
||||
const [currentItems, setCurrentItems] = useState<LabelInfo[]>([]);
|
||||
|
||||
// Update items when labelData changes
|
||||
useEffect(() => {
|
||||
if (labelData) {
|
||||
setCurrentItems(cloneDeep(labelData));
|
||||
}
|
||||
}, [labelData]);
|
||||
|
||||
// Original component used measurementData?.label in constructor,
|
||||
// let's try to initialize based on that if initialLabel is not provided.
|
||||
useEffect(() => {
|
||||
if (!initialLabel && measurementData?.label) {
|
||||
setValue(measurementData.label);
|
||||
}
|
||||
}, [initialLabel, measurementData]);
|
||||
|
||||
return (
|
||||
<Command className="border-input border">
|
||||
<CommandInput placeholder={placeholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<ScrollArea className="h-[300px]">
|
||||
<CommandGroup>
|
||||
{currentItems.map(item => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
onSelect={currentValue => {
|
||||
const newValue = currentValue === value ? '' : currentValue;
|
||||
setValue(newValue);
|
||||
labellingDoneCallback(newValue);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn('mr-2 h-4 w-4', value === item.value ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabellingFlow;
|
||||
@@ -0,0 +1,2 @@
|
||||
import LabellingFlow from './LabellingFlow';
|
||||
export default LabellingFlow;
|
||||
@@ -0,0 +1,40 @@
|
||||
.LineChart svg {
|
||||
color: #ddd;
|
||||
border: solid 1px #383f42;
|
||||
}
|
||||
|
||||
.LineChart svg .background {
|
||||
stroke: none;
|
||||
fill: #151c20;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.label {
|
||||
fill: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.LineChart svg .axis .tick line {
|
||||
stroke: #ddd;
|
||||
}
|
||||
|
||||
.LineChart svg .axis .tick text {
|
||||
fill: #ddd;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.grid .domain {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.grid .tick line {
|
||||
stroke: #383f42;
|
||||
}
|
||||
|
||||
.LineChart svg .line {
|
||||
stroke-width: 1px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.LineChart svg .legend .legend-background {
|
||||
fill: none;
|
||||
stroke: #383f42;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as d3Selection from 'd3-selection';
|
||||
import { lineChart } from './d3LineChart';
|
||||
import './LineChart.css';
|
||||
|
||||
const LineChart = ({
|
||||
width: widthProp,
|
||||
height: heightProp,
|
||||
axis,
|
||||
series,
|
||||
showAxisLabels = true,
|
||||
showAxisGrid = true,
|
||||
showLegend = false,
|
||||
legendWidth = 120,
|
||||
transparentChartBackground = false,
|
||||
containerClassName,
|
||||
chartContainerClassName,
|
||||
}: {
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
showAxisGrid: boolean;
|
||||
showAxisLabels: boolean;
|
||||
showLegend: boolean;
|
||||
legendWidth: number;
|
||||
transparentChartBackground: boolean;
|
||||
containerClassName: string;
|
||||
chartContainerClassName: string;
|
||||
}): JSX.Element => {
|
||||
const chartContainerRef = useRef(null);
|
||||
const [d3SVGContainer, setD3SVGRef] = useState(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const chartContainer = chartContainerRef.current;
|
||||
const containerWidth = chartContainer.offsetWidth;
|
||||
const containerHeight = chartContainer.offsetHeight;
|
||||
const d3Container = d3Selection
|
||||
.create('svg')
|
||||
.attr('viewBox', [0, 0, containerWidth, containerHeight])
|
||||
.style('max-width', `${containerWidth}px`)
|
||||
.style('overflow', 'visible');
|
||||
|
||||
chartContainer.append(d3Container.node());
|
||||
|
||||
setD3SVGRef(d3Container);
|
||||
setWidth(containerWidth);
|
||||
setHeight(containerHeight);
|
||||
}, [chartContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!d3SVGContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
lineChart.addLineChartNode({
|
||||
d3SVGRef: d3SVGContainer,
|
||||
axis,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
showAxisLabels,
|
||||
showAxisGrid,
|
||||
showLegend,
|
||||
legendWidth,
|
||||
transparentChartBackground,
|
||||
});
|
||||
}, [
|
||||
d3SVGContainer,
|
||||
axis,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
showAxisLabels,
|
||||
showAxisGrid,
|
||||
transparentChartBackground,
|
||||
showLegend,
|
||||
legendWidth,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
'LineChart text-white',
|
||||
{
|
||||
[`w-[${widthProp}px]`]: !!widthProp,
|
||||
[`h-[${heightProp}px]`]: !!heightProp,
|
||||
},
|
||||
{
|
||||
'w-full': !widthProp,
|
||||
'h-full': !heightProp,
|
||||
},
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
id="chartContainer"
|
||||
ref={chartContainerRef}
|
||||
className={classnames('h-full w-full', chartContainerClassName)}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LineChart.propTypes = {
|
||||
title: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
showAxisLabels: PropTypes.bool,
|
||||
showAxisGrid: PropTypes.bool,
|
||||
showLegend: PropTypes.bool,
|
||||
legendWidth: PropTypes.number,
|
||||
transparentChartBackground: PropTypes.bool,
|
||||
containerClassName: PropTypes.string,
|
||||
chartContainerClassName: PropTypes.string,
|
||||
};
|
||||
|
||||
export default LineChart;
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* It adds attributes to root element from passed attributes list
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {object} attributes list of attributes to be added on root element
|
||||
* @modifies {root}
|
||||
*
|
||||
*/
|
||||
const _addAttributes = (root, attributes = []) => {
|
||||
for (const attr of attributes) {
|
||||
const { key, value } = attr;
|
||||
|
||||
if (key && value) {
|
||||
if (key === 'class') {
|
||||
root.classed(value, true);
|
||||
} else {
|
||||
root.attr(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* It formats axis labels. It adds units if existing
|
||||
*
|
||||
* @param {Object} def
|
||||
* @param {string} def.label string representing axis label
|
||||
* @param {string} [def.unit] string representing axis unit
|
||||
* @return {string} formatted label
|
||||
*/
|
||||
const _formatAxisLabel = ({ label, unit }) => {
|
||||
let formattedLabel = label;
|
||||
|
||||
if (unit) {
|
||||
formattedLabel += `(${unit})`;
|
||||
}
|
||||
|
||||
return formattedLabel;
|
||||
};
|
||||
|
||||
/**
|
||||
* It appends chart svg element container to root element
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {number} width width of container
|
||||
* @param {number} height height of container
|
||||
* @param {number} translateX value to translate on axis x
|
||||
* @param {number} translateY value to translate on axis y
|
||||
* @modifies {root}
|
||||
*
|
||||
* @return {object} appended chart container (svg element)
|
||||
*/
|
||||
const _addChartContainer = (root, width, height, translateX, translateY) => {
|
||||
root.append('rect').attr('class', 'background').attr('width', width).attr('height', height);
|
||||
|
||||
return root
|
||||
.append('g')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.append('g')
|
||||
.attr('transform', 'translate(' + translateX + ',' + translateY + ')');
|
||||
};
|
||||
|
||||
const _addLegend = (root, width, height, posX, posY) => {
|
||||
const legendContainer = root
|
||||
.append('g')
|
||||
.attr('class', 'legend')
|
||||
.attr('transform', 'translate(' + posX + ',' + posY + ')');
|
||||
|
||||
legendContainer
|
||||
.append('rect')
|
||||
.attr('class', 'legend-background')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
return legendContainer;
|
||||
};
|
||||
|
||||
const _setLegendLabels = (root, labels, colors, itemHeight, itemWidth, textEllipses) => {
|
||||
const yOffset = itemHeight / 2;
|
||||
const textLeft = 20;
|
||||
|
||||
// Add one dot in the legend for each
|
||||
root
|
||||
.selectAll('labelDots')
|
||||
.data(labels)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('cx', 10)
|
||||
.attr('cy', (_d, i) => yOffset + i * itemHeight)
|
||||
.attr('r', 5)
|
||||
.style('fill', (_d, i) => colors[i]);
|
||||
|
||||
root
|
||||
.selectAll('labelText')
|
||||
.data(labels)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('x', textLeft)
|
||||
.attr('y', (_d, i) => yOffset + i * itemHeight)
|
||||
.style('fill', (_d, i) => colors[i])
|
||||
.append('tspan')
|
||||
.style('alignment-baseline', 'middle')
|
||||
.text(d => d)
|
||||
.each(textEllipses(itemWidth - textLeft, 1));
|
||||
};
|
||||
|
||||
/**
|
||||
* It appends axis svg element to root element
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {TimecoursePointDef} axisDef definition of given axis
|
||||
* @param {number} posX position X to place content
|
||||
* @param {number} posY position Y to place content
|
||||
* @param {number} axisExtraAttrs list of extra attributes to be added on content
|
||||
* @param {function} axisBuilder callback function to create axis graphics
|
||||
* @param {boolean} showAxisLabels flag to display labels or not
|
||||
* @param {number} labelPosX position X to place label content
|
||||
* @param {number} labelPosY position Y to place label content
|
||||
* @param {object} labelExtraAttrs list of extra attributes to be added on label content
|
||||
* @modifies {root}
|
||||
*
|
||||
* @return {object} axis svg element
|
||||
*/
|
||||
const _addAxis = (
|
||||
root,
|
||||
axisDef,
|
||||
posX = 0,
|
||||
posY = 0,
|
||||
axisExtraAttrs = [],
|
||||
axisBuilder,
|
||||
showAxisLabels,
|
||||
labelPosX,
|
||||
labelPosY,
|
||||
labelExtraAttrs = [],
|
||||
showAxisGrid
|
||||
) => {
|
||||
const { type } = axisDef;
|
||||
const showAxisGridClass = showAxisGrid ? 'grid' : '';
|
||||
const translateValue = `${posX}, ${posY}`;
|
||||
const axisContent = root
|
||||
.append('g')
|
||||
.attr('class', `${type} ${showAxisGridClass} axis`)
|
||||
.attr('transform', `translate(${translateValue})`);
|
||||
|
||||
_addAttributes(axisContent, axisExtraAttrs);
|
||||
axisContent.call(axisBuilder());
|
||||
|
||||
if (showAxisLabels) {
|
||||
const label = _formatAxisLabel(axisDef);
|
||||
// text label for the x axis
|
||||
if (label) {
|
||||
const labelContent = root
|
||||
.append('text')
|
||||
.attr('class', 'label axis')
|
||||
.attr('x', labelPosX)
|
||||
.attr('y', labelPosY)
|
||||
.style('text-anchor', 'middle')
|
||||
.text(label);
|
||||
|
||||
_addAttributes(labelContent, labelExtraAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
return axisContent;
|
||||
};
|
||||
|
||||
/**
|
||||
* It scales axis graphics
|
||||
*
|
||||
* @param {object} sourceGraphicsXAxis original d3 graphics to be proccessed (x Axis)
|
||||
* @param {object} sourceGraphicsYAxis original d3 graphics to be proccessed (y Axis)
|
||||
* @param {object} xAxisGenerator d3 X axis generator
|
||||
* @param {object} yAxisGenerator d3 Y axis generator
|
||||
* @param {object} newXAxisScale d3 continuos scale for X Axis
|
||||
* @param {object} newYAxisScale d3 continuos scale for Y Axis
|
||||
* @modifies {sourceGraphicsXAxis|sourceGraphicsYAxis}
|
||||
*/
|
||||
const _scaleAxisGraphics = (
|
||||
sourceGraphicsXAxis,
|
||||
sourceGraphicsYAxis,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
newXAxisScale,
|
||||
newYAxisScale
|
||||
) => {
|
||||
const newXAxis = xAxisGenerator.scale(newXAxisScale);
|
||||
const newYAxis = yAxisGenerator.scale(newYAxisScale);
|
||||
|
||||
sourceGraphicsXAxis.call(newXAxis);
|
||||
sourceGraphicsYAxis.call(newYAxis);
|
||||
};
|
||||
|
||||
/**
|
||||
* It appends background svg element to root element and also a clip path for chart boundaries
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {number} width content`s width
|
||||
* @param {number} height content`s height
|
||||
* @param {boolean} transparent background to be transparent or not
|
||||
* @param {number} offset content`s clip offset
|
||||
* @modifies {root}
|
||||
*
|
||||
*/
|
||||
const _addChartClipPath = (root, width, height, transparent = false, offset = 6) => {
|
||||
const translateOffset = -offset / 2;
|
||||
root
|
||||
.append('clipPath')
|
||||
.attr('id', 'clip')
|
||||
.append('rect')
|
||||
.attr('transform', `translate(${translateOffset}, ${translateOffset})`)
|
||||
.attr('width', width + offset)
|
||||
.attr('height', height + offset);
|
||||
|
||||
root
|
||||
.append('rect')
|
||||
.attr('class', 'background')
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.classed('transparent', transparent);
|
||||
};
|
||||
|
||||
const _addSeries = (root, seriesIndex, style) => {
|
||||
return root
|
||||
.append('g')
|
||||
.attr('id', `series_${seriesIndex}`)
|
||||
.attr('class', 'series')
|
||||
.attr('stroke', style.color || '#ffffff');
|
||||
};
|
||||
|
||||
/**
|
||||
* It appends line chart svg element to root element
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {object} dataset dataset to tie to content
|
||||
* @param {string} dAttrValue path d attribute
|
||||
* @modifies {root}
|
||||
*
|
||||
* @return {object} svg line element line
|
||||
*/
|
||||
const _addLine = (root, dataset, dAttrValue) => {
|
||||
return root
|
||||
.append('path')
|
||||
.attr('clip-path', 'url(#clip)')
|
||||
.datum(dataset)
|
||||
.attr('class', 'line')
|
||||
.attr('d', dAttrValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* It updates line chart svg element with new dataset and dAttrValue value
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {object} dataset dataset to tie to content
|
||||
* @param {string} dAttrValue path d attribute
|
||||
* @modifies {root}
|
||||
*
|
||||
*/
|
||||
const _updateLine = (root, dataset = [], dAttrValue) => {
|
||||
const line = root.select('.line');
|
||||
|
||||
if (dataset && dataset.length > 0) {
|
||||
line.datum(dataset);
|
||||
}
|
||||
|
||||
line.attr('d', dAttrValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* It appends points svg element to root element
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {object} dataset dataset to tie to content
|
||||
* @param {number} cxAttrValue cx attribute
|
||||
* @param {number} cyAttrValue cy attribute
|
||||
* @modifies {root}
|
||||
*
|
||||
* @return {object} points svg element
|
||||
*
|
||||
*/
|
||||
const _addPoints = (root, dataset, cxAttrValue, cyAttrValue) => {
|
||||
return root
|
||||
.selectAll('.dot')
|
||||
.data(dataset)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('id', (point, index) => {
|
||||
return `point-${index}`;
|
||||
})
|
||||
.attr('class', 'dot')
|
||||
.attr('clip-path', 'url(#clip)')
|
||||
.attr('cx', cxAttrValue)
|
||||
.attr('cy', cyAttrValue)
|
||||
.attr('r', 2);
|
||||
};
|
||||
|
||||
/**
|
||||
* It return points from root parent
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @return {object} points svg element
|
||||
*
|
||||
*/
|
||||
const _getPoints = root => {
|
||||
return root.selectAll('.dot');
|
||||
};
|
||||
|
||||
/**
|
||||
* It updates points svg element with new dataset, cxAttrValue and cyAttrValue
|
||||
*
|
||||
* @param {object} root svg element to be changed
|
||||
* @param {object} dataset dataset to tie to content
|
||||
* @param {number} cxAttrValue cx attribute
|
||||
* @param {number} cyAttrValue cy attribute
|
||||
* @modifies {root}
|
||||
*
|
||||
*/
|
||||
const _updatePoints = (root, dataset = [], cxAttrValue, cyAttrValue) => {
|
||||
const dot = root.selectAll('.dot');
|
||||
|
||||
dot.data(dataset).attr('cx', cxAttrValue).attr('cy', cyAttrValue);
|
||||
};
|
||||
|
||||
/**
|
||||
* It removes children nodes from root param.
|
||||
*
|
||||
* @param {object} root svg element to remove children content
|
||||
* @modifies {root}
|
||||
*
|
||||
*/
|
||||
const _removeChartContents = root => {
|
||||
root.selectAll('*').remove();
|
||||
};
|
||||
|
||||
const chart = {
|
||||
axis: {
|
||||
addNode: _addAxis,
|
||||
scaleGraphics: _scaleAxisGraphics,
|
||||
},
|
||||
series: {
|
||||
addNode: _addSeries,
|
||||
},
|
||||
lines: {
|
||||
addNode: _addLine,
|
||||
updateNode: _updateLine,
|
||||
},
|
||||
points: {
|
||||
addNode: _addPoints,
|
||||
updateNode: _updatePoints,
|
||||
getPoints: _getPoints,
|
||||
},
|
||||
background: {
|
||||
addNode: _addChartClipPath,
|
||||
},
|
||||
container: {
|
||||
addNode: _addChartContainer,
|
||||
},
|
||||
legend: {
|
||||
addNode: _addLegend,
|
||||
setLabels: _setLegendLabels,
|
||||
},
|
||||
removeContents: _removeChartContents,
|
||||
};
|
||||
|
||||
export default chart;
|
||||
@@ -0,0 +1,149 @@
|
||||
import * as d3Shape from 'd3-shape';
|
||||
import * as d3Zoom from 'd3-zoom';
|
||||
|
||||
import chart from './chart';
|
||||
|
||||
const d3ZoomNativeEvents = [
|
||||
'wheel.zoom',
|
||||
'mousedown.zoom',
|
||||
'dblclick.zoom',
|
||||
'touchstart.zoom',
|
||||
'touchmove.zoom',
|
||||
'touchend.zoom touchcancel.zoom',
|
||||
];
|
||||
/**
|
||||
* Object to override native zoom events.
|
||||
* It defines a specific callback for a event name.
|
||||
*
|
||||
* @type {Object<string, function>} zoomEvents <zoom d3 event name, callback method>
|
||||
*/
|
||||
const zoomEvents = {
|
||||
'dblclick.zoom': (root, zoom) => {
|
||||
_resetZoom(root, zoom);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* It resets given root
|
||||
*
|
||||
* @param {object} root svg element to be reset
|
||||
* @param {object} zoom d3 zoom object
|
||||
*
|
||||
* @modifies {root}
|
||||
*/
|
||||
const _resetZoom = (root, zoom) => {
|
||||
if (root) {
|
||||
root.transition().duration(750).call(zoom.transform, d3Zoom.zoomIdentity);
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_ZOOM_SCALE = 20;
|
||||
|
||||
const _zoom = d3Zoom.zoom().scaleExtent([1, DEFAULT_MAX_ZOOM_SCALE]);
|
||||
|
||||
const _bindZoom = (
|
||||
root,
|
||||
gX,
|
||||
gY,
|
||||
xAxisScale,
|
||||
yAxisScale,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
parseXPoint,
|
||||
parseYPoint,
|
||||
datasets
|
||||
) => {
|
||||
_zoom.on('zoom', _zoomListener); // .filter(zoomEventsFilter);
|
||||
|
||||
_removeListeners();
|
||||
_setListeners();
|
||||
|
||||
function _removeListeners() {
|
||||
for (const eventName of d3ZoomNativeEvents) {
|
||||
root.on(eventName, null);
|
||||
}
|
||||
}
|
||||
|
||||
function _setListeners() {
|
||||
const keys = Object.keys(zoomEvents);
|
||||
|
||||
for (const key of keys) {
|
||||
root.call(_zoom).on(key, zoomEvents[key].bind(undefined, root, _zoom));
|
||||
}
|
||||
}
|
||||
|
||||
function _zoomListener(event) {
|
||||
const transformedXAxisScale = event.transform.rescaleX(xAxisScale);
|
||||
const transformedYAxisScale = event.transform.rescaleY(yAxisScale);
|
||||
|
||||
chart.axis.scaleGraphics(
|
||||
gX,
|
||||
gY,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
transformedXAxisScale,
|
||||
transformedYAxisScale
|
||||
);
|
||||
|
||||
datasets.forEach((dataset, seriesIndex) => {
|
||||
const seriesContainer = root.select(`#series_${seriesIndex}`);
|
||||
// create line
|
||||
const line = d3Shape
|
||||
.line()
|
||||
.x(parseXPoint(transformedXAxisScale))
|
||||
.y(parseYPoint(transformedYAxisScale));
|
||||
|
||||
chart.lines.updateNode(seriesContainer, dataset, line);
|
||||
|
||||
chart.points.updateNode(
|
||||
seriesContainer,
|
||||
dataset,
|
||||
parseXPoint(transformedXAxisScale),
|
||||
parseYPoint(transformedYAxisScale)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return _zoom;
|
||||
};
|
||||
|
||||
const bindMouseEvents = (
|
||||
root,
|
||||
gX,
|
||||
gY,
|
||||
xAxisScale,
|
||||
yAxisScale,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
parseXPoint,
|
||||
parseYPoint,
|
||||
datasets
|
||||
) => {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
_bindZoom(
|
||||
root,
|
||||
gX,
|
||||
gY,
|
||||
xAxisScale,
|
||||
yAxisScale,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
parseXPoint,
|
||||
parseYPoint,
|
||||
datasets
|
||||
);
|
||||
};
|
||||
|
||||
const events = {
|
||||
bindMouseEvents,
|
||||
external: {
|
||||
resetZoom: root => {
|
||||
_resetZoom(root, _zoom);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default events;
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as lineChart from './lineChart';
|
||||
|
||||
export { lineChart };
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as d3Array from 'd3-array';
|
||||
import * as d3Axis from 'd3-axis';
|
||||
import * as d3Scale from 'd3-scale';
|
||||
import * as d3ScaleChromatic from 'd3-scale-chromatic';
|
||||
import * as d3Selection from 'd3-selection';
|
||||
import * as d3Shape from 'd3-shape';
|
||||
|
||||
import chart from './chart';
|
||||
import events from './events';
|
||||
|
||||
const {
|
||||
external: { resetZoom },
|
||||
} = events;
|
||||
|
||||
/**
|
||||
* @typedef TimecoursePoint It defines a tuple of timecourse value (time X intensity)
|
||||
* @type {array}
|
||||
* @property {number} 0 indicates x|y value on the pair (x,y)
|
||||
* @property {number} 1 indicates x|y value on the pair (x,y)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef TimecoursePointDef It defines the shape of a given TimecoursePoint Axis
|
||||
* @type {object}
|
||||
* @property {string} label label for given TimecoursePoint Axis
|
||||
* @property {string} [unit] unit for given TimecoursePoint Axis
|
||||
* @property {string} type defines the type of given TimecoursePoint Axis
|
||||
* @property {number} indexRef refers the index into given TimecoursePoint for the current Axis Definition
|
||||
*/
|
||||
|
||||
/**
|
||||
* It gets max value of an array, considering value of param index
|
||||
*
|
||||
* @param {TimecoursePoint[]} array array of items to be evaluated
|
||||
* @param {number} index index of each array`s item to be evaluated
|
||||
* @return {any} max value
|
||||
*/
|
||||
function _getMaxValue(array, index) {
|
||||
return d3Array.max(array, arrayItem => {
|
||||
return arrayItem[index];
|
||||
});
|
||||
}
|
||||
|
||||
function _getMinValue(array, index) {
|
||||
return d3Array.min(array, arrayItem => {
|
||||
return arrayItem[index];
|
||||
});
|
||||
}
|
||||
|
||||
const LEGEND = { width: 100, margin: 10 };
|
||||
const MARGIN = { top: 20, right: 20, bottom: 50, left: 50 };
|
||||
|
||||
function _createAxisScale(domainBottom, domainUpper, rangeBottom, rangeUpper) {
|
||||
return d3Scale
|
||||
.scaleLinear()
|
||||
.domain([domainBottom, domainUpper * 1.05])
|
||||
.range([rangeBottom, rangeUpper]);
|
||||
}
|
||||
|
||||
const _getSeriesColor = series => {
|
||||
const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []);
|
||||
|
||||
return d3Scale.scaleOrdinal().domain(seriesLabels).range(d3ScaleChromatic.schemeSet2);
|
||||
};
|
||||
|
||||
const _updateSeriesColors = series => {
|
||||
const seriesColor = _getSeriesColor(series);
|
||||
|
||||
return series.map(series => ({
|
||||
...series,
|
||||
color: series.color ?? seriesColor(series.label),
|
||||
}));
|
||||
};
|
||||
|
||||
const _textEllipses = (width, padding = 0) => {
|
||||
return function (...args) {
|
||||
const self = d3Selection.select(this);
|
||||
let textLength = self.node().getComputedTextLength();
|
||||
let text = self.text();
|
||||
|
||||
while (textLength > width - 2 * padding && text.length > 0) {
|
||||
text = text.slice(0, -1);
|
||||
self.text(text + '...');
|
||||
textLength = self.node().getComputedTextLength();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const _addLegend = (root, series, chartWidth, chartHeight, legendWidth) => {
|
||||
const legendItemHeight = 25;
|
||||
const legendHeight = legendItemHeight * series.length;
|
||||
|
||||
const legendContainer = chart.legend.addNode(
|
||||
root,
|
||||
legendWidth,
|
||||
legendHeight,
|
||||
chartWidth + LEGEND.margin,
|
||||
chartHeight / 2 - legendHeight / 2
|
||||
);
|
||||
|
||||
const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []);
|
||||
|
||||
const seriesColors = series.reduce((colors, series) => [...colors, series.color], []);
|
||||
|
||||
chart.legend.setLabels(
|
||||
legendContainer,
|
||||
seriesLabels,
|
||||
seriesColors,
|
||||
legendItemHeight,
|
||||
legendWidth,
|
||||
_textEllipses
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* It creates a svg chart containing lines, dots, axis, labels
|
||||
*
|
||||
* @param {object} d3SVGRef svg content reference to append chart
|
||||
* @param {Object<string, TimecoursePointDef>} axis definition of axis
|
||||
* @param {object} points list of points to be created
|
||||
* @param {number} width width for whole content including lines, dots, axis, labels
|
||||
* @param {number} height height for whole content including lines, dots, axis, labels
|
||||
* @param {boolean} showAxisLabels flag to display labels or not
|
||||
*
|
||||
* @modifies {d3SVGRef}
|
||||
*/
|
||||
const addLineChartNode = ({
|
||||
d3SVGRef,
|
||||
axis,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
legendWidth = LEGEND.width,
|
||||
showAxisLabels = true,
|
||||
showAxisGrid = false,
|
||||
showLegend = false,
|
||||
transparentChartBackground = false,
|
||||
}) => {
|
||||
const marginRight = showLegend ? legendWidth + 2 * LEGEND.margin : MARGIN.right;
|
||||
const _width = width - MARGIN.left - marginRight;
|
||||
const _height = height - MARGIN.top - MARGIN.bottom;
|
||||
const { x: XAxis, y: YAxis } = axis;
|
||||
|
||||
series = _updateSeriesColors(series);
|
||||
|
||||
let maxX = -Infinity;
|
||||
let minX = Infinity;
|
||||
let maxY = -Infinity;
|
||||
let minY = Infinity;
|
||||
|
||||
series.forEach(currentSeries => {
|
||||
minX = Math.min(minX, _getMinValue(currentSeries.points, XAxis.indexRef));
|
||||
maxX = Math.max(maxX, _getMaxValue(currentSeries.points, XAxis.indexRef));
|
||||
minY = Math.min(minY, _getMinValue(currentSeries.points, YAxis.indexRef));
|
||||
maxY = Math.max(maxY, _getMaxValue(currentSeries.points, YAxis.indexRef));
|
||||
});
|
||||
|
||||
minX = axis?.x?.range?.min ?? minX;
|
||||
maxX = axis?.x?.range?.max ?? maxX;
|
||||
minY = axis?.y?.range?.min ?? minY;
|
||||
maxY = axis?.y?.range?.max ?? maxY;
|
||||
|
||||
const xAxisScale = _createAxisScale(minX, maxX, 0, _width);
|
||||
const yAxisScale = _createAxisScale(minY, maxY, _height, 0);
|
||||
|
||||
const parseXPoint = axisScale => point => {
|
||||
return (axisScale || xAxisScale)(point.x);
|
||||
};
|
||||
|
||||
const parseYPoint = axisScale => point => {
|
||||
return (axisScale || yAxisScale)(point.y);
|
||||
};
|
||||
|
||||
// Remove old D3 elements
|
||||
chart.removeContents(d3SVGRef);
|
||||
|
||||
const chartWrapper = chart.container.addNode(d3SVGRef, width, height, MARGIN.left, MARGIN.top);
|
||||
|
||||
// add background
|
||||
chart.background.addNode(chartWrapper, _width, _height, transparentChartBackground);
|
||||
|
||||
// call the x axis in a group tag
|
||||
const xAxisGenerator = d3Axis.axisBottom(xAxisScale);
|
||||
|
||||
if (showAxisGrid) {
|
||||
xAxisGenerator.tickSize(-_height).tickPadding(10);
|
||||
}
|
||||
const gXAxis = chart.axis.addNode(
|
||||
chartWrapper,
|
||||
XAxis,
|
||||
undefined,
|
||||
_height,
|
||||
undefined,
|
||||
() => xAxisGenerator,
|
||||
showAxisLabels,
|
||||
_width / 2,
|
||||
_height + MARGIN.bottom / 2 + 10,
|
||||
undefined,
|
||||
showAxisGrid
|
||||
);
|
||||
const yAxisGenerator = d3Axis.axisLeft(yAxisScale);
|
||||
|
||||
if (showAxisGrid) {
|
||||
yAxisGenerator.tickSize(-_width).tickPadding(10);
|
||||
}
|
||||
// add y axis
|
||||
const gYAxis = chart.axis.addNode(
|
||||
chartWrapper,
|
||||
YAxis,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
() => yAxisGenerator,
|
||||
showAxisLabels,
|
||||
0 - _height / 2,
|
||||
0 - MARGIN.left,
|
||||
[
|
||||
{ key: 'transform', value: 'rotate(-90)' },
|
||||
{ key: 'dy', value: '1em' },
|
||||
],
|
||||
showAxisGrid
|
||||
);
|
||||
|
||||
const datasets = [];
|
||||
|
||||
series.forEach((currentSeries, seriesIndex) => {
|
||||
const { points } = currentSeries;
|
||||
const line = d3Shape.line().x(parseXPoint(xAxisScale)).y(parseYPoint(yAxisScale));
|
||||
|
||||
const dataset = points.map((point, pointIndex) => {
|
||||
return { x: point[0], y: point[1], seriesIndex, pointIndex };
|
||||
});
|
||||
|
||||
const seriesContainer = chart.series.addNode(chartWrapper, seriesIndex, {
|
||||
color: currentSeries.color || '#00ff00',
|
||||
});
|
||||
|
||||
chart.lines.addNode(seriesContainer, dataset, line);
|
||||
|
||||
// add chart points
|
||||
chart.points.addNode(
|
||||
seriesContainer,
|
||||
dataset,
|
||||
parseXPoint(xAxisScale),
|
||||
parseYPoint(yAxisScale)
|
||||
);
|
||||
|
||||
datasets.push(dataset);
|
||||
});
|
||||
|
||||
if (showLegend) {
|
||||
_addLegend(chartWrapper, series, _width, _height, legendWidth);
|
||||
}
|
||||
|
||||
// bind events
|
||||
events.bindMouseEvents(
|
||||
chartWrapper,
|
||||
gXAxis,
|
||||
gYAxis,
|
||||
xAxisScale,
|
||||
yAxisScale,
|
||||
xAxisGenerator,
|
||||
yAxisGenerator,
|
||||
parseXPoint,
|
||||
parseYPoint,
|
||||
datasets
|
||||
);
|
||||
|
||||
return chartWrapper;
|
||||
};
|
||||
|
||||
export { addLineChartNode, resetZoom };
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './LineChart';
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ProgressLoadingBar from '../ProgressLoadingBar';
|
||||
import { Icons } from '../Icons';
|
||||
/**
|
||||
* A React component that renders a loading indicator.
|
||||
* if progress is not provided, it will render an infinite loading indicator
|
||||
* if progress is provided, it will render a progress bar
|
||||
* Optionally a textBlock can be provided to display a message
|
||||
*/
|
||||
function LoadingIndicatorProgress({ className, textBlock, progress }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute top-0 left-0 z-50 flex flex-col items-center justify-center space-y-5',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icons.LoadingOHIFMark className="h-12 w-12 text-white" />
|
||||
<div className="w-48">
|
||||
<ProgressLoadingBar progress={progress} />
|
||||
</div>
|
||||
{textBlock}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIndicatorProgress;
|
||||
@@ -0,0 +1,2 @@
|
||||
import LoadingIndicatorProgress from './LoadingIndicatorProgress';
|
||||
export default LoadingIndicatorProgress;
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import LoadingIndicatorProgress from '../LoadingIndicatorProgress';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
totalNumbers: number | null;
|
||||
percentComplete: number | null;
|
||||
loadingText?: string;
|
||||
targetText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A React component that renders a loading indicator but accepts a totalNumbers
|
||||
* and percentComplete to display a more detailed message.
|
||||
*/
|
||||
function LoadingIndicatorTotalPercent({
|
||||
className,
|
||||
totalNumbers,
|
||||
percentComplete,
|
||||
loadingText = 'Loading...',
|
||||
targetText = 'segments',
|
||||
}: Props): JSX.Element {
|
||||
const progress = percentComplete;
|
||||
const totalNumbersText = totalNumbers !== null ? `${totalNumbers}` : '';
|
||||
const numTargetsLoadedText =
|
||||
percentComplete !== null ? Math.floor((percentComplete * totalNumbers) / 100) : '';
|
||||
|
||||
const textBlock =
|
||||
!totalNumbers && percentComplete === null ? (
|
||||
<div className="text-sm text-white">{loadingText}</div>
|
||||
) : !totalNumbers && percentComplete !== null ? (
|
||||
<div className="text-sm text-white">Loaded {percentComplete}%</div>
|
||||
) : (
|
||||
<div className="text-sm text-white">
|
||||
Loaded {numTargetsLoadedText} of {totalNumbersText} {targetText}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingIndicatorProgress
|
||||
className={className}
|
||||
progress={progress}
|
||||
textBlock={textBlock}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIndicatorTotalPercent;
|
||||
@@ -0,0 +1,2 @@
|
||||
import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent';
|
||||
export default LoadingIndicatorTotalPercent;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
|
||||
|
||||
const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }): ReactElement => {
|
||||
return (
|
||||
<div className="flex">
|
||||
{options.map((option, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classnames('mr-1 h-1 grow first:rounded-l-sm last:mr-0 last:rounded-r-sm', {
|
||||
'bg-black': !option.activated && !option.completed,
|
||||
'bg-primary-main': option.activated && !option.completed,
|
||||
'bg-primary-light': option.completed,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressDiscreteBar.propTypes = {
|
||||
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
|
||||
};
|
||||
|
||||
export default ProgressDiscreteBar;
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { ReactNode, useEffect, useCallback, useState, useMemo, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import ProgressDiscreteBar from './ProgressDiscreteBar';
|
||||
import ProgressItemDetail from './ProgressItemDetail';
|
||||
import ProgressItem from './ProgressItem';
|
||||
import { Icons } from '../Icons';
|
||||
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
|
||||
|
||||
const ProgressDropdown = ({
|
||||
options: optionsProps,
|
||||
value,
|
||||
children,
|
||||
dropDownWidth = '170',
|
||||
onChange,
|
||||
}: {
|
||||
options: ProgressDropdownOption[];
|
||||
value?: string;
|
||||
children?: ReactNode;
|
||||
onChange?: ({ selectedOption }) => void;
|
||||
}): JSX.Element => {
|
||||
const element = useRef(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleOpen = () => setOpen(s => !s);
|
||||
const [options, setOptions] = useState(optionsProps);
|
||||
const [selectedOption, setSelectedOption] = useState(undefined);
|
||||
|
||||
const selectedOptionIndex = useMemo(
|
||||
() => options.findIndex(option => option.value === selectedOption?.value),
|
||||
[options, selectedOption]
|
||||
);
|
||||
|
||||
const canMoveNext = useMemo(
|
||||
() => selectedOptionIndex < options.length - 1,
|
||||
[selectedOptionIndex, options]
|
||||
);
|
||||
|
||||
const handleOptionSelected = useCallback(
|
||||
(newSelectedOption?: ProgressDropdownOption): void => {
|
||||
if (newSelectedOption?.value === selectedOption?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setSelectedOption(newSelectedOption);
|
||||
|
||||
if (newSelectedOption) {
|
||||
newSelectedOption.activated = true;
|
||||
newSelectedOption.onSelect?.();
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange({ selectedOption: newSelectedOption });
|
||||
}
|
||||
},
|
||||
[selectedOption, onChange]
|
||||
);
|
||||
|
||||
const handleNextButtonClick = useCallback(() => {
|
||||
if (canMoveNext) {
|
||||
handleOptionSelected(options[selectedOptionIndex + 1]);
|
||||
}
|
||||
}, [options, selectedOptionIndex, canMoveNext, handleOptionSelected]);
|
||||
|
||||
// Update the options in case the options from props has changed
|
||||
useEffect(() => setOptions(optionsProps), [optionsProps]);
|
||||
|
||||
// Updates the selected item based on the value from props
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOption = value ? options.find(option => option.value === value) : undefined;
|
||||
|
||||
handleOptionSelected(newOption);
|
||||
}, [value, options, handleOptionSelected]);
|
||||
|
||||
// Listen to any click event outside of the dropdown context to hide the options
|
||||
useEffect(() => {
|
||||
const handleDocumentClick = e => {
|
||||
if (element.current && !element.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
|
||||
if (!open) {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element}
|
||||
className="relative grow select-none text-[0px] text-white"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1.5 flex h-[26px]">
|
||||
<div
|
||||
className="bg-secondary-dark border-primary-main flex grow cursor-pointer rounded border"
|
||||
style={{ width: `${dropDownWidth}px` }}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<div className="flex grow">
|
||||
{selectedOption && <ProgressItemDetail option={selectedOption} />}
|
||||
|
||||
{!selectedOption && <div className="ml-1 grow text-base leading-6">{children}</div>}
|
||||
</div>
|
||||
<Icons.ChevronDown className="text-primary-active mt-1.5 ml-1 mr-2" />
|
||||
</div>
|
||||
<button
|
||||
className={classnames('ml-1.5 w-[26px] rounded text-base', {
|
||||
'bg-primary-main': canMoveNext,
|
||||
'bg-primary-dark pointer-events-none': !canMoveNext,
|
||||
})}
|
||||
>
|
||||
<Icons.ArrowRight
|
||||
className={classnames('relative left-0.5 h-6 w-6 text-white', {
|
||||
'text-white': canMoveNext,
|
||||
'text-secondary-light': !canMoveNext,
|
||||
})}
|
||||
onClick={handleNextButtonClick}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={classnames(
|
||||
'absolute top-7 left-0 right-8 z-10 mt-0.5 origin-top',
|
||||
'bg-primary-dark overflow-hidden transition-[max-height] duration-300',
|
||||
'border-secondary-main rounded border shadow',
|
||||
{
|
||||
hidden: !open,
|
||||
'max-h-[500px]': open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<ProgressItem
|
||||
key={index}
|
||||
option={option}
|
||||
onSelect={() => handleOptionSelected(option)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProgressDiscreteBar options={options} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressDropdown.propTypes = {
|
||||
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
dropDownWidth: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ProgressDropdown;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ProgressItemDetail from './ProgressItemDetail';
|
||||
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
|
||||
|
||||
const ProgressItem = ({
|
||||
option,
|
||||
onSelect,
|
||||
}: {
|
||||
option: ProgressDropdownOption;
|
||||
onSelect: (option: ProgressDropdownOption) => void;
|
||||
}): ReactElement => {
|
||||
const { value } = option;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={'hover:bg-secondary-main flex cursor-pointer py-1 transition duration-1000'}
|
||||
onClick={() => onSelect(option)}
|
||||
>
|
||||
<ProgressItemDetail option={option} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressItem.propTypes = {
|
||||
option: ProgressDropdownOptionPropType.isRequired,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ProgressItem;
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useState, useMemo, ReactElement } from 'react';
|
||||
import { Icons } from '../Icons';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../Tooltip';
|
||||
import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types';
|
||||
|
||||
const MAX_TOOLTIP_LENGTH = 150;
|
||||
const iconClassNames = 'grow-0 text-primary-light h-4 w-4 mt-1 mr-2 mb-0 ml-1';
|
||||
|
||||
const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): ReactElement => {
|
||||
const { label, info, completed } = option;
|
||||
const [truncate, setTruncate] = useState(true);
|
||||
const handleOnHideTooltip = () => setTruncate(true);
|
||||
let icon;
|
||||
|
||||
if (completed) {
|
||||
icon = 'status-tracked';
|
||||
} else if (info) {
|
||||
icon = 'launch-info';
|
||||
}
|
||||
|
||||
const tooltipText = useMemo(() => {
|
||||
if (!truncate || !info || info.length <= MAX_TOOLTIP_LENGTH) {
|
||||
return info;
|
||||
}
|
||||
|
||||
const handleReadMoreClick = e => {
|
||||
setTruncate(false);
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{info.slice(0, MAX_TOOLTIP_LENGTH)}
|
||||
<button
|
||||
className="text-primary-active font-bold"
|
||||
onClick={handleReadMoreClick}
|
||||
>
|
||||
Read more...
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}, [info, truncate]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
handleOnHideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const iconContent = (
|
||||
<>
|
||||
{icon && (
|
||||
<div>
|
||||
<Icons.ByName
|
||||
name={icon}
|
||||
className={iconClassNames}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!icon && <div className={iconClassNames} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{info && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip onOpenChange={handleOpenChange}>
|
||||
<TooltipTrigger asChild>{iconContent}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className={'max-w-xs'}
|
||||
>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!info && iconContent}
|
||||
|
||||
<div className="grow overflow-hidden text-ellipsis whitespace-nowrap text-base leading-6">
|
||||
{label}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressItemDetail.propTypes = {
|
||||
option: ProgressDropdownOptionPropType.isRequired,
|
||||
};
|
||||
|
||||
export default ProgressItemDetail;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ProgressDropdown';
|
||||
@@ -0,0 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export type ProgressDropdownOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
info?: string;
|
||||
activated?: boolean;
|
||||
completed?: boolean;
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export const ProgressDropdownOptionPropType = PropTypes.shape({
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
info: PropTypes.string,
|
||||
activated: PropTypes.bool,
|
||||
completed: PropTypes.bool,
|
||||
onSelect: PropTypes.func,
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
.loading {
|
||||
background-color: #091731;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.infinite-loading-bar {
|
||||
animation: side2side 2s ease-in-out infinite;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
border-radius: 4px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@keyframes side2side {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(150%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import './ProgressLoadingBar.css';
|
||||
|
||||
export type ProgressLoadingBarProps = {
|
||||
progress?: number;
|
||||
};
|
||||
/**
|
||||
* A React component that renders a loading progress bar.
|
||||
* If progress is not provided, it will render an infinite loading bar
|
||||
* If progress is provided, it will render a progress bar
|
||||
* The progress text can be optionally displayed to the left of the bar.
|
||||
*/
|
||||
function ProgressLoadingBar({ progress }: ProgressLoadingBarProps): ReactElement {
|
||||
return (
|
||||
<div className="loading">
|
||||
{progress === undefined || progress === null ? (
|
||||
<div className="infinite-loading-bar bg-primary-light"></div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-primary-light"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
height: '8px',
|
||||
}}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProgressLoadingBar;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ProgressLoadingBar from './ProgressLoadingBar';
|
||||
export default ProgressLoadingBar;
|
||||
@@ -59,9 +59,11 @@ function ToolButton(props: ToolButtonProps) {
|
||||
className
|
||||
);
|
||||
|
||||
const defaultTooltip = tooltip || label;
|
||||
const defaultTooltip = label;
|
||||
const disabledTooltip = disabled && disabledText ? disabledText : null;
|
||||
const hasTooltip = defaultTooltip || disabledTooltip;
|
||||
const hasSecondaryTooltip = tooltip || disabledTooltip;
|
||||
|
||||
const showTooltip = hasSecondaryTooltip || defaultTooltip;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
@@ -85,7 +87,7 @@ function ToolButton(props: ToolButtonProps) {
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={hasTooltip ? defaultTooltip : undefined}
|
||||
aria-label={defaultTooltip}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icons.ByName
|
||||
@@ -95,12 +97,19 @@ function ToolButton(props: ToolButtonProps) {
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{hasTooltip && (
|
||||
<>
|
||||
<div>{defaultTooltip}</div>
|
||||
{disabledTooltip && <div className="text-muted-foreground">{disabledTooltip}</div>}
|
||||
</>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="text-wrap w-auto max-w-sm whitespace-normal break-words"
|
||||
>
|
||||
{showTooltip && (
|
||||
<div className="space-y-1">
|
||||
{defaultTooltip && <div className="text-sm">{defaultTooltip}</div>}
|
||||
{disabledTooltip ? (
|
||||
<div className="text-muted-foreground text-xs">{disabledTooltip}</div>
|
||||
) : (
|
||||
tooltip && <div className="text-muted-foreground text-xs">{tooltip}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Button } from '../Button';
|
||||
import { Icons } from '../Icons';
|
||||
|
||||
type NotificationAction = {
|
||||
id: string;
|
||||
text: string;
|
||||
value: any;
|
||||
type?: 'primary' | 'secondary';
|
||||
size?: 'sm' | 'md';
|
||||
};
|
||||
|
||||
export type ViewportDialogProps = {
|
||||
id: string;
|
||||
type: 'error' | 'warning' | 'info' | 'success';
|
||||
message: string | React.ReactNode;
|
||||
actions: NotificationAction[];
|
||||
onSubmit: (e: any) => void;
|
||||
onOutsideClick?: () => void;
|
||||
onKeyPress?: (e: React.KeyboardEvent) => void;
|
||||
};
|
||||
|
||||
const ViewportDialog: React.FC<ViewportDialogProps> = ({
|
||||
id,
|
||||
type = 'info',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick = () => {},
|
||||
onKeyPress,
|
||||
}) => {
|
||||
const notificationRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const notificationElement = notificationRef.current;
|
||||
const handleClick = function (event) {
|
||||
const isClickInside = notificationElement.contains(event.target);
|
||||
|
||||
if (!isClickInside) {
|
||||
onOutsideClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Both a mouse down and up listeners are desired so as to avoid missing events
|
||||
// from elements that have pointer-events:none (e.g. the active viewport).
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('mouseup', handleClick);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('mouseup', handleClick);
|
||||
};
|
||||
}, [onOutsideClick]);
|
||||
|
||||
useEffect(() => {
|
||||
notificationRef.current.focus();
|
||||
}, []);
|
||||
|
||||
const iconsByType = {
|
||||
error: {
|
||||
icon: 'info',
|
||||
color: 'text-red-700',
|
||||
},
|
||||
warning: {
|
||||
icon: 'notificationwarning-diamond',
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
info: {
|
||||
icon: 'notifications-info',
|
||||
color: 'text-primary-main',
|
||||
},
|
||||
success: {
|
||||
icon: 'info',
|
||||
color: 'text-green-500',
|
||||
},
|
||||
};
|
||||
|
||||
const getIconData = () => {
|
||||
return (
|
||||
iconsByType[type] || {
|
||||
icon: '',
|
||||
color: '',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const { icon, color } = getIconData();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={notificationRef}
|
||||
className="border-input bg-muted mx-2 mt-2 flex flex-col rounded-md border p-2 outline-none"
|
||||
data-cy={id}
|
||||
onKeyDown={onKeyPress}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
<Icons.ByName
|
||||
name={icon}
|
||||
className={classnames('h-5 w-5', color)}
|
||||
/>
|
||||
<span className="text-foreground ml-2 text-base">{message}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap justify-end gap-2">
|
||||
{actions?.map((action, index) => {
|
||||
return (
|
||||
<Button
|
||||
name={action.id}
|
||||
key={index}
|
||||
variant={action.type === 'secondary' ? 'secondary' : 'default'}
|
||||
size={action.size === 'sm' ? 'sm' : 'default'}
|
||||
onClick={() => {
|
||||
onSubmit(action.value);
|
||||
}}
|
||||
className="min-w-16"
|
||||
>
|
||||
{action.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewportDialog;
|
||||
@@ -0,0 +1,2 @@
|
||||
import ViewportDialog from './ViewportDialog';
|
||||
export default ViewportDialog;
|
||||
@@ -62,7 +62,11 @@ import Modal from './Modal/Modal';
|
||||
import { FooterAction } from './FooterAction';
|
||||
import { InputFilter } from './InputFilter';
|
||||
import { WindowLevel, WindowLevelHistogram } from './OHIFPanels';
|
||||
|
||||
import ProgressDropdown from './ProgressDropdown';
|
||||
import LoadingIndicatorProgress from './LoadingIndicatorProgress';
|
||||
import LoadingIndicatorTotalPercent from './LoadingIndicatorTotalPercent';
|
||||
import ProgressLoadingBar from './ProgressLoadingBar';
|
||||
import ViewportDialog from './ViewportDialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -119,6 +123,9 @@ export { MeasurementTable } from './MeasurementTable';
|
||||
export * from './ColorCircle';
|
||||
export { default as AllInOneMenu } from './AllInOneMenu';
|
||||
export * from './AllInOneMenu';
|
||||
export { default as LineChart } from './LineChart';
|
||||
export { default as InvestigationalUseDialog } from './InvestigationalUseDialog';
|
||||
export { default as LabellingFlow } from './Labelling';
|
||||
|
||||
// Segmentation Context Exports
|
||||
export { useSegmentationTableContext, useSegmentationExpanded, useSegmentStatistics };
|
||||
@@ -255,5 +262,10 @@ export {
|
||||
InputFilter,
|
||||
WindowLevel,
|
||||
WindowLevelHistogram,
|
||||
ProgressDropdown,
|
||||
LoadingIndicatorProgress,
|
||||
LoadingIndicatorTotalPercent,
|
||||
ProgressLoadingBar,
|
||||
ViewportDialog,
|
||||
CinePlayer,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
|
||||
// import TouchBackend from 'react-dnd-touch-backend';
|
||||
|
||||
// TODO: this is false when it should not be :thinking:
|
||||
const isTouchDevice =
|
||||
typeof window !== `undefined` && !!('ontouchstart' in window || navigator.maxTouchPoints);
|
||||
|
||||
/**
|
||||
* Relevant:
|
||||
* https://github.com/react-dnd/react-dnd/issues/186#issuecomment-335429067
|
||||
* https://github.com/react-dnd/react-dnd/issues/186#issuecomment-282789420
|
||||
*
|
||||
* Docs:
|
||||
* http://react-dnd.github.io/react-dnd/docs/api/drag-drop-context
|
||||
*/
|
||||
function DragAndDropProvider({ children }) {
|
||||
const backend = HTML5Backend; // isTouchDevice ? TouchBackend : HTML5Backend;
|
||||
const opts = {}; // isTouchDevice ? { enableMouseEvents: true } : {};
|
||||
|
||||
console.log('using... touch backend?', isTouchDevice);
|
||||
|
||||
return (
|
||||
<DndProvider
|
||||
backend={backend}
|
||||
opts={opts}
|
||||
>
|
||||
{children}
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
||||
DragAndDropProvider.propTypes = {
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
export default DragAndDropProvider;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode, createContext, useContext, useMemo } from 'react';
|
||||
|
||||
interface ImageViewerProviderProps {
|
||||
StudyInstanceUIDs: string[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ImageViewerContext = createContext(null);
|
||||
export const useImageViewer = () => useContext(ImageViewerContext);
|
||||
|
||||
export function ImageViewerProvider({ StudyInstanceUIDs, children }: ImageViewerProviderProps) {
|
||||
const value = useMemo(() => {
|
||||
return { StudyInstanceUIDs };
|
||||
}, [StudyInstanceUIDs]);
|
||||
|
||||
return <ImageViewerContext.Provider value={value}>{children}</ImageViewerContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useReducer } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
user: null,
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
export const UserAuthenticationContext = createContext(DEFAULT_STATE);
|
||||
|
||||
export function UserAuthenticationProvider({ children, service }) {
|
||||
const userAuthenticationReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_USER': {
|
||||
return {
|
||||
...state,
|
||||
...{ user: action.payload.user },
|
||||
};
|
||||
}
|
||||
case 'RESET': {
|
||||
return {
|
||||
user: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET': {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return action.payload;
|
||||
}
|
||||
};
|
||||
|
||||
const [userAuthenticationState, dispatch] = useReducer(userAuthenticationReducer, DEFAULT_STATE);
|
||||
|
||||
const getState = useCallback(() => userAuthenticationState, [userAuthenticationState]);
|
||||
|
||||
const setUser = useCallback(
|
||||
user =>
|
||||
dispatch({
|
||||
type: 'SET_USER',
|
||||
payload: {
|
||||
user,
|
||||
},
|
||||
}),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const getUser = useCallback(() => userAuthenticationState.user, [userAuthenticationState]);
|
||||
|
||||
const reset = useCallback(
|
||||
() =>
|
||||
dispatch({
|
||||
type: 'RESET',
|
||||
payload: {},
|
||||
}),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const set = useCallback(
|
||||
payload =>
|
||||
dispatch({
|
||||
type: 'SET',
|
||||
payload,
|
||||
}),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* Sets the implementation of the UserAuthenticationService that can be used by extensions.
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
// TODO: should this be a useEffect or not?
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({
|
||||
getState,
|
||||
setUser,
|
||||
getUser,
|
||||
reset,
|
||||
set,
|
||||
});
|
||||
}
|
||||
}, [getState, service, setUser, getUser, reset, set]);
|
||||
|
||||
// TODO: This may not be correct, but I think we need to set the implementation for the service
|
||||
// immediately when this runs, since otherwise the authentication redirects will fail.
|
||||
// (useEffect only runs after the child components - in this case, routing logic - has failed)
|
||||
if (service) {
|
||||
service.setServiceImplementation({
|
||||
getState,
|
||||
setUser,
|
||||
getUser,
|
||||
reset,
|
||||
set,
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
getState,
|
||||
setUser,
|
||||
getUser,
|
||||
getAuthorizationHeader: service.getAuthorizationHeader,
|
||||
handleUnauthenticated: service.handleUnauthenticated,
|
||||
reset,
|
||||
set,
|
||||
};
|
||||
|
||||
return (
|
||||
<UserAuthenticationContext.Provider value={[userAuthenticationState, api]}>
|
||||
{children}
|
||||
</UserAuthenticationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserAuthenticationProvider;
|
||||
|
||||
const UserAuthenticationConsumer = UserAuthenticationContext.Consumer;
|
||||
export { UserAuthenticationConsumer };
|
||||
|
||||
UserAuthenticationProvider.propTypes = {
|
||||
children: PropTypes.any,
|
||||
service: PropTypes.shape({
|
||||
setServiceImplementation: PropTypes.func,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export const useUserAuthentication = () => useContext(UserAuthenticationContext);
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useState, createContext, useContext, useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
message: undefined,
|
||||
type: 'info', // "error" | "warning" | "info" | "success"
|
||||
actions: undefined, // array of { type, text, value }
|
||||
onSubmit: () => {
|
||||
console.log('btn value?');
|
||||
},
|
||||
onOutsideClick: () => {
|
||||
console.warn('default: onOutsideClick');
|
||||
},
|
||||
onDismiss: () => {
|
||||
console.log('dismiss? -1');
|
||||
},
|
||||
onKeyPress: () => {
|
||||
console.log('key pressed?');
|
||||
},
|
||||
};
|
||||
|
||||
const ViewportDialogContext = createContext(null);
|
||||
const { Provider } = ViewportDialogContext;
|
||||
|
||||
export const useViewportDialog = () => useContext(ViewportDialogContext);
|
||||
|
||||
const ViewportDialogProvider = ({ children, service }) => {
|
||||
const [viewportDialogState, setViewportDialogState] = useState(DEFAULT_STATE);
|
||||
const show = useCallback(
|
||||
params => setViewportDialogState({ ...viewportDialogState, ...params }),
|
||||
[viewportDialogState]
|
||||
);
|
||||
const hide = useCallback(() => setViewportDialogState(DEFAULT_STATE), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({ hide, show });
|
||||
}
|
||||
}, [hide, service, show]);
|
||||
|
||||
return <Provider value={[viewportDialogState, { show, hide }]}>{children}</Provider>;
|
||||
};
|
||||
|
||||
ViewportDialogProvider.propTypes = {
|
||||
/** Children that will be wrapped with Modal Context */
|
||||
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
|
||||
service: PropTypes.shape({
|
||||
setServiceImplementation: PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
export default ViewportDialogProvider;
|
||||
@@ -3,6 +3,11 @@ import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './Vi
|
||||
import { ModalProvider, useModal } from './ModalProvider';
|
||||
import { DialogProvider, useDialog } from './DialogProvider';
|
||||
import ManagedDialog from './ManagedDialog';
|
||||
import ViewportDialogProvider from './ViewportDialogProvider';
|
||||
import { useViewportDialog } from './ViewportDialogProvider';
|
||||
import { UserAuthenticationProvider, useUserAuthentication } from './UserAuthenticationProvider';
|
||||
import { ImageViewerContext, ImageViewerProvider, useImageViewer } from './ImageViewerProvider';
|
||||
import DragAndDropProvider from './DragAndDropProvider';
|
||||
import CineProvider, { useCine } from './CineProvider';
|
||||
|
||||
export { useNotification, NotificationProvider };
|
||||
@@ -10,4 +15,8 @@ export { ViewportGridContext, ViewportGridProvider, useViewportGrid };
|
||||
export { ModalProvider, useModal };
|
||||
export { DialogProvider, useDialog };
|
||||
export { ManagedDialog };
|
||||
export { CineProvider, useCine };
|
||||
export { ViewportDialogProvider, useViewportDialog };
|
||||
export { UserAuthenticationProvider, useUserAuthentication };
|
||||
export { ImageViewerContext, ImageViewerProvider, useImageViewer };
|
||||
export { DragAndDropProvider };
|
||||
export { CineProvider, useCine };
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as utils from './utils';
|
||||
export * from './components';
|
||||
export * from './contextProviders';
|
||||
export * as Types from './types';
|
||||
|
||||
export { utils };
|
||||
Reference in new issue
Block a user