* feat(custom displayOptions): add custom options to every level of hanging protocols * move set preset down to cornerstone3d * works * fix tmtv based on new api * fix unit test * close * review1 * upgrade packages * update packages * apply review comments * recursive call * less copy * apply review comments * fix e2e tests * apply review comments * revert * apply review comments * apply review comments --------- Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
437 lines
14 KiB
TypeScript
437 lines
14 KiB
TypeScript
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
ReactElement,
|
|
} from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
|
|
import DicomFileUploader, {
|
|
EVENTS,
|
|
UploadStatus,
|
|
DicomFileUploaderProgressEvent,
|
|
UploadRejection,
|
|
} from '../../utils/DicomFileUploader';
|
|
import DicomUploadProgressItem from './DicomUploadProgressItem';
|
|
import classNames from 'classnames';
|
|
|
|
type DicomUploadProgressProps = {
|
|
dicomFileUploaderArr: DicomFileUploader[];
|
|
onComplete: () => void;
|
|
};
|
|
|
|
const ONE_SECOND = 1000;
|
|
const ONE_MINUTE = ONE_SECOND * 60;
|
|
const ONE_HOUR = ONE_MINUTE * 60;
|
|
|
|
// The base/initial interval time length used to calculate the
|
|
// rate of the upload and in turn estimate the
|
|
// the amount of time remaining for the upload. This is the length
|
|
// of the very first interval to get a reasonable estimate on screen in
|
|
// a reasonable amount of time. The length of each interval after the first
|
|
// is based on the upload rate calculated. Faster rates use this base interval
|
|
// length. Slower rates below UPLOAD_RATE_THRESHOLD get longer interval times
|
|
// to obtain more accurate upload rates.
|
|
const BASE_INTERVAL_TIME = 15000;
|
|
|
|
// The upload rate threshold to determine the length of the interval to
|
|
// calculate the upload rate.
|
|
const UPLOAD_RATE_THRESHOLD = 75;
|
|
|
|
const NO_WRAP_ELLIPSIS_CLASS_NAMES =
|
|
'text-ellipsis whitespace-nowrap overflow-hidden';
|
|
|
|
function DicomUploadProgress({
|
|
dicomFileUploaderArr,
|
|
onComplete,
|
|
}: DicomUploadProgressProps): ReactElement {
|
|
const [totalUploadSize] = useState(
|
|
dicomFileUploaderArr.reduce(
|
|
(acc, fileUploader) => acc + fileUploader.getFileSize(),
|
|
0
|
|
)
|
|
);
|
|
|
|
const currentUploadSizeRef = useRef<number>(0);
|
|
|
|
const uploadRateRef = useRef(0);
|
|
|
|
const [timeRemaining, setTimeRemaining] = useState<number>(null);
|
|
|
|
const [percentComplete, setPercentComplete] = useState(0);
|
|
|
|
const [numFilesCompleted, setNumFilesCompleted] = useState(0);
|
|
|
|
const [numFails, setNumFails] = useState(0);
|
|
|
|
const [showFailedOnly, setShowFailedOnly] = useState(false);
|
|
|
|
const progressBarContainerRef = useRef<HTMLElement>();
|
|
|
|
/**
|
|
* The effect for measuring and setting the current upload rate. This is
|
|
* done by measuring the amount of data uploaded in a set interval time.
|
|
*/
|
|
useEffect(() => {
|
|
let timeoutId: NodeJS.Timeout;
|
|
|
|
// The amount of data already uploaded at the start of the interval.
|
|
let intervalStartUploadSize = 0;
|
|
|
|
// The starting time of the interval.
|
|
let intervalStartTime = Date.now();
|
|
|
|
const setUploadRateRef = () => {
|
|
const uploadSizeFromStartOfInterval =
|
|
currentUploadSizeRef.current - intervalStartUploadSize;
|
|
|
|
const now = Date.now();
|
|
const timeSinceStartOfInterval = now - intervalStartTime;
|
|
|
|
// Calculate and set the upload rate (ref)
|
|
uploadRateRef.current =
|
|
uploadSizeFromStartOfInterval / timeSinceStartOfInterval;
|
|
|
|
// Reset the interval starting values.
|
|
intervalStartUploadSize = currentUploadSizeRef.current;
|
|
intervalStartTime = now;
|
|
|
|
// Only start a new interval if there is more to upload.
|
|
if (totalUploadSize - currentUploadSizeRef.current > 0) {
|
|
if (uploadRateRef.current >= UPLOAD_RATE_THRESHOLD) {
|
|
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
|
} else {
|
|
// The current upload rate is relatively slow, so use a larger
|
|
// time interval to get a better upload rate estimate.
|
|
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME * 2);
|
|
}
|
|
}
|
|
};
|
|
|
|
// The very first interval is just the base time interval length.
|
|
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
};
|
|
}, []);
|
|
|
|
/**
|
|
* The effect for: updating the overall percentage complete; setting the
|
|
* estimated time remaining; updating the number of files uploaded; and
|
|
* detecting if any error has occurred.
|
|
*/
|
|
useEffect(() => {
|
|
let currentTimeRemaining = null;
|
|
|
|
// For each uploader, listen for the progress percentage complete and
|
|
// add promise catch/finally callbacks to detect errors and count number
|
|
// of uploads complete.
|
|
const subscriptions = dicomFileUploaderArr.map(fileUploader => {
|
|
let currentFileUploadSize = 0;
|
|
|
|
const updateProgress = (percentComplete: number) => {
|
|
const previousFileUploadSize = currentFileUploadSize;
|
|
|
|
currentFileUploadSize = Math.round(
|
|
(percentComplete / 100) * fileUploader.getFileSize()
|
|
);
|
|
|
|
currentUploadSizeRef.current = Math.min(
|
|
totalUploadSize,
|
|
currentUploadSizeRef.current -
|
|
previousFileUploadSize +
|
|
currentFileUploadSize
|
|
);
|
|
|
|
setPercentComplete(
|
|
(currentUploadSizeRef.current / totalUploadSize) * 100
|
|
);
|
|
|
|
if (uploadRateRef.current !== 0) {
|
|
const uploadSizeRemaining =
|
|
totalUploadSize - currentUploadSizeRef.current;
|
|
|
|
const timeRemaining = Math.round(
|
|
uploadSizeRemaining / uploadRateRef.current
|
|
);
|
|
|
|
if (currentTimeRemaining === null) {
|
|
currentTimeRemaining = timeRemaining;
|
|
setTimeRemaining(currentTimeRemaining);
|
|
return;
|
|
}
|
|
|
|
// Do not show an increase in the time remaining by two seconds or minutes
|
|
// so as to prevent jumping the time remaining up and down constantly
|
|
// due to rounding, inaccuracies in the estimate and slight variations
|
|
// in upload rates over time.
|
|
if (timeRemaining < ONE_MINUTE) {
|
|
const currentSecondsRemaining = Math.ceil(
|
|
currentTimeRemaining / ONE_SECOND
|
|
);
|
|
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
|
const delta = secondsRemaining - currentSecondsRemaining;
|
|
if (delta < 0 || delta > 2) {
|
|
currentTimeRemaining = timeRemaining;
|
|
setTimeRemaining(currentTimeRemaining);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (timeRemaining < ONE_HOUR) {
|
|
const currentMinutesRemaining = Math.ceil(
|
|
currentTimeRemaining / ONE_MINUTE
|
|
);
|
|
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
|
const delta = minutesRemaining - currentMinutesRemaining;
|
|
if (delta < 0 || delta > 2) {
|
|
currentTimeRemaining = timeRemaining;
|
|
setTimeRemaining(currentTimeRemaining);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Hours remaining...
|
|
currentTimeRemaining = timeRemaining;
|
|
setTimeRemaining(currentTimeRemaining);
|
|
}
|
|
};
|
|
|
|
const progressCallback = (
|
|
progressEvent: DicomFileUploaderProgressEvent
|
|
) => {
|
|
updateProgress(progressEvent.percentComplete);
|
|
};
|
|
|
|
// Use the uploader promise to flag any error and count the number of
|
|
// uploads completed.
|
|
fileUploader
|
|
.load()
|
|
.catch((rejection: UploadRejection) => {
|
|
if (rejection.status === UploadStatus.Failed) {
|
|
setNumFails(numFails => numFails + 1);
|
|
}
|
|
})
|
|
.finally(() => {
|
|
// If any error occurred, the percent complete progress stops firing
|
|
// but this call to updateProgress nicely puts all finished uploads at 100%.
|
|
updateProgress(100);
|
|
setNumFilesCompleted(numCompleted => numCompleted + 1);
|
|
});
|
|
|
|
return fileUploader.subscribe(EVENTS.PROGRESS, progressCallback);
|
|
});
|
|
return () => {
|
|
subscriptions.forEach(subscription => subscription.unsubscribe());
|
|
};
|
|
}, []);
|
|
|
|
const cancelAllUploads = useCallback(async () => {
|
|
for (const dicomFileUploader of dicomFileUploaderArr) {
|
|
// Important: we need a non-blocking way to cancel every upload,
|
|
// otherwise the UI will freeze and the user will not be able
|
|
// to interact with the app and progress will not be updated.
|
|
const promise = new Promise<void>((resolve, reject) => {
|
|
setTimeout(() => {
|
|
dicomFileUploader.cancel();
|
|
resolve();
|
|
}, 0);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const getFormattedTimeRemaining = useCallback((): string => {
|
|
if (timeRemaining == null) {
|
|
return '';
|
|
}
|
|
|
|
if (timeRemaining < ONE_MINUTE) {
|
|
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
|
return `${secondsRemaining} ${
|
|
secondsRemaining === 1 ? 'second' : 'seconds'
|
|
}`;
|
|
}
|
|
|
|
if (timeRemaining < ONE_HOUR) {
|
|
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
|
return `${minutesRemaining} ${
|
|
minutesRemaining === 1 ? 'minute' : 'minutes'
|
|
}`;
|
|
}
|
|
|
|
const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR);
|
|
return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`;
|
|
}, [timeRemaining]);
|
|
|
|
const getPercentCompleteRounded = useCallback(
|
|
() => Math.min(100, Math.round(percentComplete)),
|
|
[percentComplete]
|
|
);
|
|
|
|
/**
|
|
* Determines if the progress bar should show the infinite animation or not.
|
|
* Show the infinite animation for progress less than 1% AND if less than
|
|
* one pixel of the progress bar would be displayed.
|
|
*/
|
|
const showInfiniteProgressBar = useCallback((): boolean => {
|
|
return (
|
|
getPercentCompleteRounded() < 1 &&
|
|
(progressBarContainerRef?.current?.offsetWidth ?? 0) *
|
|
(percentComplete / 100) <
|
|
1
|
|
);
|
|
}, [getPercentCompleteRounded, percentComplete]);
|
|
|
|
/**
|
|
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
|
|
* of the style is width such that the 'n of m' is always a fixed width and thus
|
|
* as each file completes uploading the text on screen does not constantly shift
|
|
* left and right.
|
|
*/
|
|
const getNofMFilesStyle = useCallback(() => {
|
|
// the number of digits accounts for the digits being on each side of the ' of '
|
|
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
|
|
// the number of digits + 2 spaces and 2 characters for ' of '
|
|
const numChars = numDigits + 4;
|
|
return { width: `${numChars}ch` };
|
|
}, []);
|
|
|
|
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
|
|
return (
|
|
<div className="text-lg px-1 pb-4 h-14 flex bg-primary-dark items-center">
|
|
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
|
<>
|
|
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${
|
|
dicomFileUploaderArr.length
|
|
} ${
|
|
dicomFileUploaderArr.length > 1 ? 'files' : 'file'
|
|
} completed.`}</span>
|
|
<Button
|
|
variant="contained"
|
|
color="primary"
|
|
disabled={false}
|
|
className="ml-auto"
|
|
onClick={onComplete}
|
|
>
|
|
{'Close'}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span
|
|
style={getNofMFilesStyle()}
|
|
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
|
|
>
|
|
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}
|
|
</span>
|
|
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
|
|
{' files completed.'}
|
|
</span>
|
|
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
|
|
{timeRemaining
|
|
? `Less than ${getFormattedTimeRemaining()} remaining. `
|
|
: ''}
|
|
</span>
|
|
<span
|
|
className={classNames(
|
|
NO_WRAP_ELLIPSIS_CLASS_NAMES,
|
|
'cursor-pointer text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto'
|
|
)}
|
|
onClick={cancelAllUploads}
|
|
>
|
|
Cancel All Uploads
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getShowFailedOnlyIconComponent = (): ReactElement => {
|
|
return (
|
|
<div className="ml-auto flex justify-center w-6">
|
|
{numFails > 0 && (
|
|
<div
|
|
onClick={() =>
|
|
setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)
|
|
}
|
|
>
|
|
<Icon className="cursor-pointer" name="icon-status-alert"></Icon>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getPercentCompleteComponent = (): ReactElement => {
|
|
return (
|
|
<div className="overflow-y-scroll ohif-scrollbar px-2 border-b border-secondary-light">
|
|
<div className="flex w-full p-2.5 items-center min-h-14">
|
|
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
|
<>
|
|
<div className="text-xl text-primary-light">
|
|
{numFails > 0
|
|
? `Completed with ${numFails} ${
|
|
numFails > 1 ? 'errors' : 'error'
|
|
}!`
|
|
: 'Completed!'}
|
|
</div>
|
|
{getShowFailedOnlyIconComponent()}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div ref={progressBarContainerRef} className="flex-grow">
|
|
<ProgressLoadingBar
|
|
progress={
|
|
showInfiniteProgressBar()
|
|
? undefined
|
|
: Math.min(100, percentComplete)
|
|
}
|
|
></ProgressLoadingBar>
|
|
</div>
|
|
<div className="w-24 ml-1 flex items-center">
|
|
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
|
|
{getShowFailedOnlyIconComponent()}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col grow">
|
|
{getNumCompletedAndTimeRemainingComponent()}
|
|
<div className="flex flex-col bg-black text-lg overflow-hidden grow">
|
|
{getPercentCompleteComponent()}
|
|
<div className="overflow-y-scroll ohif-scrollbar px-2 grow h-1">
|
|
{dicomFileUploaderArr
|
|
.filter(
|
|
dicomFileUploader =>
|
|
!showFailedOnly ||
|
|
dicomFileUploader.getStatus() === UploadStatus.Failed
|
|
)
|
|
.map(dicomFileUploader => (
|
|
<DicomUploadProgressItem
|
|
key={dicomFileUploader.getFileId()}
|
|
dicomFileUploader={dicomFileUploader}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
DicomUploadProgress.propTypes = {
|
|
dicomFileUploaderArr: PropTypes.arrayOf(
|
|
PropTypes.instanceOf(DicomFileUploader)
|
|
).isRequired,
|
|
onComplete: PropTypes.func.isRequired,
|
|
};
|
|
|
|
export default DicomUploadProgress;
|