This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
export default class Queue {
constructor(limit) {
this.limit = limit;
this.size = 0;
this.awaiting = null;
}
/**
* Creates a new "proxy" function associated with the current execution queue
* instance. When the returned function is invoked, the queue limit is checked
* to make sure the limit of scheduled tasks is respected (throwing an
* exception when the limit has been reached and before calling the original
* function). The original function is only invoked after all the previously
* scheduled tasks have finished executing (their returned promises have
* resolved/rejected);
*
* @param {function} task The function whose execution will be associated
* with the current Queue instance;
* @returns {function} The "proxy" function bound to the current Queue
* instance;
*/
bind(task) {
return bind(this, task);
}
bindSafe(task, onError) {
const boundTask = bind(this, task);
return async function safeTask(...args) {
try {
return await boundTask(...args);
} catch (e) {
onError(e);
}
};
}
}
/**
* Utils
*/
function bind(queue, task) {
const cleaner = clean.bind(null, queue);
return async function boundTask(...args) {
if (queue.size >= queue.limit) {
throw new Error('Queue limit reached');
}
const promise = chain(queue.awaiting, task, args);
queue.awaiting = promise.then(cleaner, cleaner);
queue.size++;
return promise;
};
}
function clean(queue) {
if (queue.size > 0 && --queue.size === 0) {
queue.awaiting = null;
}
}
async function chain(prev, task, args) {
await prev;
return task(...args);
}

View File

@@ -0,0 +1,69 @@
import makeDeferred from './makeDeferred';
import Queue from './Queue';
/**
* Utils
*/
function timeout(delay) {
const { resolve, promise } = makeDeferred();
setTimeout(() => void resolve(Date.now()), delay);
return promise;
}
/**
* Tests
*/
const threshold = 2400;
describe('Queue', () => {
// Todo: comment due to wrong implementation
// it('should bind functions to the queue', async () => {
// const queue = new Queue(2);
// const mockedTimeout = jest.fn(timeout);
// const timer = queue.bind(mockedTimeout);
// const start = Date.now();
// timer(threshold).then(now => {
// const elapsed = now - start;
// expect(elapsed >= threshold && elapsed <= 2 * threshold).toBe(true);
// });
// const end = await timer(threshold);
// expect(end - start >= 2 * threshold).toBe(true);
// expect(mockedTimeout).toBeCalledTimes(2);
// });
it('should prevent task execution when queue limit is reached', async () => {
const queue = new Queue(1);
const mockedTimeout = jest.fn(timeout);
const timer = queue.bind(mockedTimeout);
const start = Date.now();
const promise = timer(threshold).then(time => time - start);
try {
await timer(threshold);
} catch (e) {
expect(Date.now() - start < threshold).toBe(true);
expect(e.message).toBe('Queue limit reached');
}
const elapsed = await promise;
expect(elapsed >= threshold && elapsed < 2 * threshold).toBe(true);
expect(mockedTimeout).toBeCalledTimes(1);
});
it('should safely bind tasks to the queue', async () => {
const queue = new Queue(1);
const mockedErrorHandler = jest.fn();
const mockedTimeout = jest.fn(timeout);
const timer = queue.bindSafe(mockedTimeout, mockedErrorHandler);
const start = Date.now();
const promise = timer(threshold).then(time => time - start);
await timer(threshold);
expect(Date.now() - start < threshold).toBe(true);
expect(mockedErrorHandler).toBeCalledTimes(1);
expect(mockedErrorHandler).nthCalledWith(
1,
expect.objectContaining({ message: 'Queue limit reached' })
);
const elapsed = await promise;
expect(elapsed >= threshold && elapsed < 2 * threshold).toBe(true);
expect(mockedTimeout).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,22 @@
const absoluteUrl = path => {
let absolutePath = '/';
if (!path) {
return absolutePath;
}
// TODO: Find another way to get root url
const absoluteUrl = window.location.origin;
const absoluteUrlParts = absoluteUrl.split('/');
if (absoluteUrlParts.length > 4) {
const rootUrlPrefixIndex = absoluteUrl.indexOf(absoluteUrlParts[3]);
absolutePath += absoluteUrl.substring(rootUrlPrefixIndex) + path;
} else {
absolutePath += path;
}
return absolutePath.replace(/\/\/+/g, '/');
};
export default absoluteUrl;

View File

@@ -0,0 +1,44 @@
import absoluteUrl from './absoluteUrl';
describe('absoluteUrl', () => {
test('should return /path_1/path_2/path_3/path_to_destination when the window.location.origin is http://dummy.com/path_1/path_2 and the path is /path_3/path_to_destination', () => {
let global = {
window: Object.create(window),
};
const url = 'http://dummy.com/path_1/path_2';
Object.defineProperty(window, 'location', {
value: {
origin: url,
},
writable: true,
});
const absoluteUrlOutput = absoluteUrl('/path_3/path_to_destination');
expect(absoluteUrlOutput).toEqual('/path_1/path_2/path_3/path_to_destination');
});
test('should return / when the path is not defined', () => {
const absoluteUrlOutput = absoluteUrl(undefined);
expect(absoluteUrlOutput).toBe('/');
});
test('should return the original path when there path in the window.origin after the domain and port', () => {
delete global.window.location;
const url = 'http://dummy.com';
global.window.location = {
origin: url,
};
const absoluteUrlOutput = absoluteUrl('path_1/path_2/path_3');
expect(absoluteUrlOutput).toEqual('/path_1/path_2/path_3');
});
test('should be able to return the absolute path even when the path contains duplicates', () => {
global.window ||= Object.create(window);
const url = 'http://dummy.com';
delete global.window.location;
global.window.location = {
origin: url,
};
const absoluteUrlOutput = absoluteUrl('path_1/path_1/path_1');
expect(absoluteUrlOutput).toEqual('/path_1/path_1/path_1');
});
});

View File

@@ -0,0 +1,66 @@
const handler = {
/**
* Get a proxied value from the array or property value
* Note that the property value get works even if you update the underlying object.
* Also, return true of proxy.__isProxy in order to distinguish proxies and not double proxy them.
*/
get: (target, prop) => {
if (prop == '__isProxy') {
return true;
}
if (prop in target) {
return target[prop];
}
return target[0][prop];
},
set: (obj, prop, value) => {
if (typeof prop === 'number' || prop in obj) {
obj[prop] = value;
} else {
obj[0][prop] = value;
}
return true;
},
};
/**
* Add a proxy object for sqZero or the src[0] element if sqZero is unspecified, AND
* src is an array of length 1.
*
* If sqZero isn't passed in, then assume this is a create call on the destination object
* itself, by:
* 1. If not an object, return dest
* 2. If an array of length != 1, return dest
* 3. If an array, use dest[0] as sqZero
* 4. Use dest as sqZero
*
* @example
* src = [{a:5,b:'string', c:null}]
* addAccessors(src)
* src.c = 'outerChange'
* src[0].b='innerChange'
*
* assert src.a===5
* assert src[0].c === 'outerChange'
* assert src.b === 'innerChange'
*/
const addAccessors = (dest, sqZero) => {
if (dest.__isProxy) {
return dest;
}
let itemZero = sqZero;
if (itemZero === undefined) {
if (typeof dest !== 'object') {
return dest;
}
if (Array.isArray(dest) && dest.length !== 1) {
return dest;
}
itemZero = Array.isArray(dest) ? dest[0] : dest;
}
const ret = [itemZero];
return new Proxy(ret, handler);
};
export default addAccessors;

View File

@@ -0,0 +1,78 @@
import addServers from './addServers';
describe('addServers', () => {
const servers = {
dicomWeb: [
{
name: 'DCM4CHEE',
wadoUriRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado',
qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
},
],
oidc: [
{
authority: 'http://127.0.0.1/auth/realms/ohif',
client_id: 'ohif-viewer',
redirect_uri: 'http://127.0.0.1/callback',
response_type: 'code',
scope: 'openid',
post_logout_redirect_uri: '/logout-redirect.html',
},
],
};
const store = {
dispatch: jest.fn(),
};
test('should be able to add a server and dispatch to the store successfuly', () => {
addServers(servers, store);
expect(store.dispatch).toBeCalledWith({
server: {
authority: 'http://127.0.0.1/auth/realms/ohif',
client_id: 'ohif-viewer',
post_logout_redirect_uri: '/logout-redirect.html',
redirect_uri: 'http://127.0.0.1/callback',
response_type: 'code',
scope: 'openid',
type: 'oidc',
},
type: 'ADD_SERVER',
});
expect(store.dispatch).toBeCalledWith({
server: {
imageRendering: 'wadors',
name: 'DCM4CHEE',
qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
qidoSupportsIncludeField: true,
thumbnailRendering: 'wadors',
type: 'dicomWeb',
wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
wadoUriRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado',
},
type: 'ADD_SERVER',
});
});
test('should throw an error if servers list is not defined', () => {
expect(() => addServers(undefined, store)).toThrowError(
new Error('The servers and store must be defined')
);
});
test('should throw an error if store is not defined', () => {
expect(() => addServers(servers, undefined)).toThrowError(
new Error('The servers and store must be defined')
);
});
test('should throw an error when both server and store are not defined', () => {
expect(() => addServers(undefined, undefined)).toThrowError(
new Error('The servers and store must be defined')
);
});
});

View File

@@ -0,0 +1,21 @@
// TODO: figure out where else to put this function
const addServers = (servers, store) => {
if (!servers || !store) {
throw new Error('The servers and store must be defined');
}
Object.keys(servers).forEach(serverType => {
const endpoints = servers[serverType];
endpoints.forEach(endpoint => {
const server = Object.assign({}, endpoint);
server.type = serverType;
store.dispatch({
type: 'ADD_SERVER',
server,
});
});
});
};
export default addServers;

View File

@@ -0,0 +1,22 @@
/* Enabled JPEG images downloading on IE11. */
const b64toBlob = (b64Data, contentType = '', sliceSize = 512) => {
const byteCharacters = atob(b64Data);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, { type: contentType });
return blob;
};
export default b64toBlob;

View File

@@ -0,0 +1,62 @@
/**
* Combine the Per instance frame data, the shared frame data
* and the root data objects.
* The data is combined by taking nested sequence objects within
* the functional group sequences. Data that is directly contained
* within the functional group sequences, such as private creators
* will be ignored.
* This can be safely called with an undefined frame in order to handle
* single frame data. (eg frame is undefined is the same as frame===1).
*/
const combineFrameInstance = (frame, instance) => {
const { PerFrameFunctionalGroupsSequence, SharedFunctionalGroupsSequence, NumberOfFrames } =
instance;
if (PerFrameFunctionalGroupsSequence || NumberOfFrames > 1) {
const frameNumber = Number.parseInt(frame || 1);
const shared = (
SharedFunctionalGroupsSequence ? Object.values(SharedFunctionalGroupsSequence[0]) : []
)
.filter(it => !!it)
.map(it => it[0])
.filter(it => it !== undefined && typeof it === 'object');
const perFrame = (
PerFrameFunctionalGroupsSequence
? Object.values(PerFrameFunctionalGroupsSequence[frameNumber - 1])
: []
)
.filter(it => !!it)
.map(it => it[0])
.filter(it => it !== undefined && typeof it === 'object');
// this is to fix NM multiframe datasets with position and orientation
// information inside DetectorInformationSequence
if (!instance.ImageOrientationPatient && instance.DetectorInformationSequence) {
instance.ImageOrientationPatient =
instance.DetectorInformationSequence[0].ImageOrientationPatient;
}
if (!instance.ImagePositionPatient && instance.DetectorInformationSequence) {
instance.ImagePositionPatient = instance.DetectorInformationSequence[0].ImagePositionPatient;
}
const newInstance = Object.assign(instance, { frameNumber: frameNumber });
// merge the shared first then the per frame to override
[...shared, ...perFrame].forEach(item => {
Object.entries(item).forEach(([key, value]) => {
newInstance[key] = value;
});
});
// Todo: we should cache this combined instance somewhere, maybe add it
// back to the dicomMetaStore so we don't have to do this again.
return {
...newInstance,
ImagePositionPatient: newInstance.ImagePositionPatient ?? [0, 0, frameNumber],
};
} else {
return instance;
}
};
export default combineFrameInstance;

View File

@@ -0,0 +1,33 @@
// Leaving here as a starting point
// import createStacks from './createStacks.js';
// describe('createStacks.js', () => {
// const seriesMetadatas = [
// {
// getInstanceCount: jest.fn().mockReturnValue(1),
// getData: jest.fn().mockReturnValue({
// SeriesDate: '2019-06-04',
// }),
// },
// {
// getInstanceCount: jest.fn().mockReturnValue(1),
// getData: jest.fn().mockReturnValue({
// SeriesDate: '2018-06-04',
// }),
// },
// ];
// const studyMetadata = {
// getSeriesCount: jest.fn().mockReturnValue(2),
// forEachSeries: jest.fn().mockImplementation(callback => {
// callback(seriesMetadatas[0], 0);
// callback(seriesMetadatas[1], 1);
// }),
// getStudyInstanceUID: jest.fn(),
// };
// it('sorts displaySets by SeriesNumber, then by SeriesDate', () => {
// const displaySets = createStacks(studyMetadata);
// expect(displaySets.length).toBe(2);
// });
// });

View File

@@ -0,0 +1,81 @@
/**
*
* @param {string[]} primaryStudyInstanceUIDs
* @param {object[]} studyDisplayList
* @param {string} studyDisplayList.studyInstanceUid
* @param {string} studyDisplayList.date
* @param {string} studyDisplayList.description
* @param {string} studyDisplayList.modalities
* @param {number} studyDisplayList.numInstances
* @param {object[]} displaySets
* @param {number} recentTimeframe - The number of milliseconds to consider a study recent
* @returns tabs - The prop object expected by the StudyBrowser component
*/
export function createStudyBrowserTabs(
primaryStudyInstanceUIDs,
studyDisplayList,
displaySets,
recentTimeframeMS = 31536000000
) {
const primaryStudies = [];
const allStudies = [];
studyDisplayList.forEach(study => {
const displaySetsForStudy = displaySets.filter(
ds => ds.StudyInstanceUID === study.studyInstanceUid
);
const tabStudy = Object.assign({}, study, {
displaySets: displaySetsForStudy,
});
if (primaryStudyInstanceUIDs.includes(study.studyInstanceUid)) {
primaryStudies.push(tabStudy);
}
allStudies.push(tabStudy);
});
const primaryStudiesTimestamps = primaryStudies
.filter(study => study.date)
.map(study => new Date(study.date).getTime());
const recentStudies =
primaryStudiesTimestamps.length > 0
? allStudies.filter(study => {
const oldestPrimaryTimeStamp = Math.min(...primaryStudiesTimestamps);
if (!study.date) {
return false;
}
const studyTimeStamp = new Date(study.date).getTime();
return oldestPrimaryTimeStamp - studyTimeStamp < recentTimeframeMS;
})
: [];
// Newest first
const _byDate = (a, b) => {
const dateA = Date.parse(a);
const dateB = Date.parse(b);
return dateB - dateA;
};
const tabs = [
{
name: 'primary',
label: 'Primary',
studies: primaryStudies.sort((studyA, studyB) => _byDate(studyA.date, studyB.date)),
},
{
name: 'recent',
label: 'Recent',
studies: recentStudies.sort((studyA, studyB) => _byDate(studyA.date, studyB.date)),
},
{
name: 'all',
label: 'All',
studies: allStudies.sort((studyA, studyB) => _byDate(studyA.date, studyB.date)),
},
];
return tabs;
}

View File

@@ -0,0 +1,25 @@
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
function debounce(func, wait, immediate) {
var timeout;
return function () {
var context = this,
args = arguments;
var later = function () {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
func.apply(context, args);
}
};
}
export default debounce;

View File

@@ -0,0 +1,105 @@
import { DicomMetadataStore } from '../services/DicomMetadataStore/DicomMetadataStore';
export default function downloadCSVReport(measurementData) {
if (measurementData.length === 0) {
// Prevent download of report with no measurements.
return;
}
const columns = [
'Patient ID',
'Patient Name',
'StudyInstanceUID',
'SeriesInstanceUID',
'SOPInstanceUID',
'Label',
];
const reportMap = {};
measurementData.forEach(measurement => {
const { referenceStudyUID, referenceSeriesUID, getReport, uid } = measurement;
if (!getReport) {
console.warn('Measurement does not have a getReport function');
return;
}
const seriesMetadata = DicomMetadataStore.getSeries(referenceStudyUID, referenceSeriesUID);
const commonRowItems = _getCommonRowItems(measurement, seriesMetadata);
const report = getReport(measurement);
reportMap[uid] = {
report,
commonRowItems,
};
});
// get columns names inside the report from each measurement and
// add them to the rows array (this way we can add columns for any custom
// measurements that may be added in the future)
Object.keys(reportMap).forEach(id => {
const { report } = reportMap[id];
report.columns.forEach(column => {
if (!columns.includes(column)) {
columns.push(column);
}
});
});
const results = _mapReportsToRowArray(reportMap, columns);
let csvContent = 'data:text/csv;charset=utf-8,' + results.map(res => res.join(',')).join('\n');
_createAndDownloadFile(csvContent);
}
function _mapReportsToRowArray(reportMap, columns) {
const results = [columns];
Object.keys(reportMap).forEach(id => {
const { report, commonRowItems } = reportMap[id];
const row = [];
// For commonRowItems, find the correct index and add the value to the
// correct row in the results array
Object.keys(commonRowItems).forEach(key => {
const index = columns.indexOf(key);
const value = commonRowItems[key];
row[index] = value;
});
// For each annotation data, find the correct index and add the value to the
// correct row in the results array
report.columns.forEach((column, index) => {
const colIndex = columns.indexOf(column);
const value = report.values[index];
row[colIndex] = value;
});
results.push(row);
});
return results;
}
function _getCommonRowItems(measurement, seriesMetadata) {
const firstInstance = seriesMetadata.instances[0];
return {
'Patient ID': firstInstance.PatientID, // Patient ID
'Patient Name': firstInstance.PatientName?.Alphabetic || '', // Patient Name
StudyInstanceUID: measurement.referenceStudyUID, // StudyInstanceUID
SeriesInstanceUID: measurement.referenceSeriesUID, // SeriesInstanceUID
SOPInstanceUID: measurement.SOPInstanceUID, // SOPInstanceUID
Label: measurement.label || '', // Label
};
}
function _createAndDownloadFile(csvContent) {
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', 'MeasurementReport.csv');
document.body.appendChild(link);
link.click();
}

View File

@@ -0,0 +1,14 @@
import moment from 'moment';
import i18n from 'i18next';
/**
* Format date
*
* @param {string} date Date to be formatted
* @param {string} format Desired date format
* @returns {string} Formatted date
*/
export default (date, format = i18n.t('Common:localDateFormat','DD-MMM-YYYY')) => {
// moment(undefined) returns the current date, so return the empty string instead
return date ? moment(date).format(format) : '';
};

View File

@@ -0,0 +1,23 @@
/**
* Formats a patient name for display purposes
*/
export default function formatPN(name) {
if (!name) {
return;
}
let nameToUse = name.Alphabetic ?? name;
if (typeof nameToUse === 'object') {
nameToUse = '';
}
// Convert the first ^ to a ', '. String.replace() only affects
// the first appearance of the character.
const commaBetweenFirstAndLast = nameToUse.replace('^', ', ');
// Replace any remaining '^' characters with spaces
const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' ');
// Trim any extraneous whitespace
return cleaned.trim();
}

View File

@@ -0,0 +1,12 @@
import moment from 'moment';
/**
* Format time in HHmmss.SSS format (24h time) into HH:mm:ss
*
* @param time - Time to be formatted
* @param format - Desired time format
* @returns Formatted time
*/
export default function formatTime(time: string, format = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(format);
}

View File

@@ -0,0 +1,63 @@
const generateAcceptHeader = (
configAcceptHeader = [],
requestTransferSyntaxUID = '*', //default to accept all transfer syntax
omitQuotationForMultipartRequest = false
): string[] => {
//if acceptedHeader is passed by config use it as it.
if (configAcceptHeader.length > 0) {
return configAcceptHeader;
}
let acceptHeader = ['multipart/related'];
let hasTransferSyntax = false;
if (requestTransferSyntaxUID && typeForTS[requestTransferSyntaxUID]) {
const type = typeForTS[requestTransferSyntaxUID];
acceptHeader.push('type=' + type);
acceptHeader.push('transfer-syntax=' + requestTransferSyntaxUID);
hasTransferSyntax = true;
} else {
acceptHeader.push('type=application/octet-stream');
}
if (!hasTransferSyntax) {
acceptHeader.push('transfer-syntax=*');
}
if (!omitQuotationForMultipartRequest) {
//need to add quotation for each mime type of each accept entry
acceptHeader = acceptHeader.map(mime => {
if (mime.startsWith('type=')) {
const quotedParam = 'type="' + mime.substring(5, mime.length) + '"';
return quotedParam;
}
if (mime.startsWith('transfer-syntax=')) {
const quotedParam = 'transfer-syntax="' + mime.substring(16, mime.length) + '"';
return quotedParam;
} else {
return mime;
}
});
}
return [acceptHeader.join('; ')];
};
const typeForTS = {
'*': 'application/octet-stream',
'1.2.840.10008.1.2.1': 'application/octet-stream',
'1.2.840.10008.1.2': 'application/octet-stream',
'1.2.840.10008.1.2.2': 'application/octet-stream',
'1.2.840.10008.1.2.4.70': 'image/jpeg',
'1.2.840.10008.1.2.4.50': 'image/jpeg',
'1.2.840.10008.1.2.4.51': 'image/dicom+jpeg',
'1.2.840.10008.1.2.4.57': 'image/jpeg',
'1.2.840.10008.1.2.5': 'image/dicom-rle',
'1.2.840.10008.1.2.4.80': 'image/jls',
'1.2.840.10008.1.2.4.81': 'image/jls',
'1.2.840.10008.1.2.4.90': 'image/jp2',
'1.2.840.10008.1.2.4.91': 'image/jp2',
'1.2.840.10008.1.2.4.92': 'image/jpx',
'1.2.840.10008.1.2.4.93': 'image/jpx',
};
export default generateAcceptHeader;

View File

@@ -0,0 +1,55 @@
import getWADORSImageId from './getWADORSImageId';
// https://stackoverflow.com/a/6021027/3895126
function updateQueryStringParameter(uri, key, value) {
const regex = new RegExp('([?&])' + key + '=.*?(&|$)', 'i');
const separator = uri.indexOf('?') !== -1 ? '&' : '?';
if (uri.match(regex)) {
return uri.replace(regex, '$1' + key + '=' + value + '$2');
} else {
return uri + separator + key + '=' + value;
}
}
/**
* Obtain an imageId for Cornerstone from an image instance
*
* @param instance
* @param frame
* @param thumbnail
* @returns {string} The imageId to be used by Cornerstone
*/
export default function getImageId(instance, frame, thumbnail = false) {
if (!instance) {
return;
}
if (instance.imageId && frame === undefined) {
return instance.imageId;
}
if (typeof instance.getImageId === 'function') {
return instance.getImageId();
}
if (instance.url) {
if (frame !== undefined) {
instance.url = updateQueryStringParameter(instance.url, 'frame', frame);
}
return instance.url;
}
const renderingAttr = thumbnail ? 'thumbnailRendering' : 'imageRendering';
if (!instance[renderingAttr] || instance[renderingAttr] === 'wadouri' || !instance.wadorsuri) {
let imageId = 'dicomweb:' + instance.wadouri;
if (frame !== undefined) {
imageId += '&frame=' + frame;
}
return imageId;
} else {
return getWADORSImageId(instance, frame, thumbnail); // WADO-RS Retrieve Frame
}
}

View File

@@ -0,0 +1,37 @@
function getWADORSImageUrl(instance, frame) {
let wadorsuri = instance.wadorsuri;
if (!wadorsuri) {
return;
}
// Use null to obtain an imageId which represents the instance
if (frame === null) {
wadorsuri = wadorsuri.replace(/frames\/(\d+)/, '');
} else {
// We need to sum 1 because WADO-RS frame number is 1-based
frame = frame ? parseInt(frame) + 1 : 1;
// Replaces /frame/1 by /frame/{frame}
wadorsuri = wadorsuri.replace(/frames\/(\d+)/, `frames/${frame}`);
}
return wadorsuri;
}
/**
* Obtain an imageId for Cornerstone based on the WADO-RS scheme
*
* @param {object} instanceMetada metadata object (InstanceMetadata)
* @param {(string\|number)} [frame] the frame number
* @returns {string} The imageId to be used by Cornerstone
*/
export default function getWADORSImageId(instance, frame) {
const uri = getWADORSImageUrl(instance, frame);
if (!uri) {
return;
}
return `wadors:${uri}`;
}

View File

@@ -0,0 +1,65 @@
import getWADORSImageId from './getWADORSImageId';
describe('getWADORSImageId', () => {
it('should always return undefined if the instance has no `wadorsuri` property', () => {
const frame = '42';
const instance = {};
expect(getWADORSImageId(instance)).toBeUndefined();
expect(getWADORSImageId(instance, frame)).toBeUndefined();
});
it('should always prepend the `wadorsuri` with `wadors:`', () => {
const frame = '42';
const instance = {
wadorsuri: 'wadorsuri',
};
expect(getWADORSImageId(instance)).toEqual('wadors:wadorsuri');
expect(getWADORSImageId(instance, frame)).toEqual('wadors:wadorsuri');
});
describe('with no frame provided', () => {
it('should replace `frames/:number` with `frames/1`', () => {
const instance = {
wadorsuri: 'frames/42',
};
expect(getWADORSImageId(instance)).toEqual('wadors:frames/1');
});
it('should work on a real wadorsuri', () => {
const instance = {
wadorsuri:
'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.1/series/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.2/instances/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.8/frames/22',
};
expect(getWADORSImageId(instance)).toEqual(
'wadors:https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.1/series/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.2/instances/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.8/frames/1'
);
});
});
describe('with a frame provided', () => {
it('should replace `frames/:number` with the argument frame plus one', () => {
const frame = '42';
const instance = {
wadorsuri: 'frames/1',
};
expect(getWADORSImageId(instance, frame)).toEqual('wadors:frames/43');
});
it('should work on a real wadorsuri', () => {
const frame = '42';
const instance = {
wadorsuri:
'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.1/series/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.2/instances/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.8/frames/22',
};
expect(getWADORSImageId(instance, frame)).toEqual(
'wadors:https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.1/series/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.2/instances/1.3.6.1.4.1.25403.52237031786.3872.20100510032220.8/frames/43'
);
});
});
});

View File

@@ -0,0 +1,28 @@
/**
* Create a random GUID
*
* @return {string}
*/
const guid = () => {
const getFourRandomValues = () => {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
};
return (
getFourRandomValues() +
getFourRandomValues() +
'-' +
getFourRandomValues() +
'-' +
getFourRandomValues() +
'-' +
getFourRandomValues() +
'-' +
getFourRandomValues() +
getFourRandomValues() +
getFourRandomValues()
);
};
export default guid;

View File

@@ -0,0 +1,46 @@
import guid from './guid';
describe('guid', () => {
Math.random = jest.fn(() => 0.4677647565236618);
const guidValue = guid();
afterAll(() => {
jest.clearAllMocks();
});
test('should return 77bf77bf-77bf-77bf-77bf-77bf77bf77bf when the random value is fixed on 0.4677647565236618', () => {
expect(guidValue).toBe('77bf77bf-77bf-77bf-77bf-77bf77bf77bf');
});
test('should always return a guid of size 36', () => {
expect(guidValue.length).toBe(36);
});
test('should always return a guid with five sequences', () => {
expect(guidValue.split('-').length).toBe(5);
});
test('should always return a guid with four dashes', () => {
expect(guidValue.split('-').length - 1).toBe(4);
});
test('should return the first sequence with length of eigth', () => {
expect(guidValue.split('-')[0].length).toBe(8);
});
test('should return the second sequence with length of four', () => {
expect(guidValue.split('-')[1].length).toBe(4);
});
test('should return the third sequence with length of four', () => {
expect(guidValue.split('-')[2].length).toBe(4);
});
test('should return the fourth sequence with length of four', () => {
expect(guidValue.split('-')[3].length).toBe(4);
});
test('should return the last sequence with length of twelve', () => {
expect(guidValue.split('-')[4].length).toBe(12);
});
});

View File

@@ -0,0 +1,195 @@
/**
* Constants
*/
const SEPARATOR = '/';
/**
* API
*/
/**
* Add values to a list hierarchically.
* @ For example:
* addToList([], 'a', 'b', 'c');
* will add the following hierarchy to the list:
* a > b > c
* resulting in the following array:
* [['a', [['b', ['c']]]]]
* @param {Array} list The target list;
* @param {...string} values The values to be hierarchically added to the list;
* @returns {Array} Returns the provided list possibly updated with the given
* values or null when a bad list (not an actual array) is provided
*/
function addToList(list, ...values) {
if (Array.isArray(list)) {
if (values.length > 0) {
addValuesToList(list, values);
}
return list;
}
return null;
}
/**
* Iterates through the provided hierarchical list executing the callback
* once for each leaf-node of the tree. The ancestors of the leaf-node being
* visited are passed to the callback function along with the leaf-node in
* the exact same order they appear on the tree (from root to leaf);
* @ For example, if the hierarchy `a > b > c` appears on the tree ("a" being
* the root and "c" being the leaf) the callback function will be called as:
* callback('a', 'b', 'c');
* @param {Array} list The hierarchical list to be iterated
* @param {function} callback The callback which will be executed once for
* each leaf-node of the hierarchical list;
* @returns {Array} Returns the provided list or null for bad arguments;
*/
function forEach(list, callback) {
if (Array.isArray(list)) {
if (typeof callback === 'function') {
forEachValue(list, callback);
}
return list;
}
return null;
}
/**
* Retrieves an item from the given hierarchical list based on an index (number)
* or a path (string).
* @ For example:
* getItem(list, '1/0/4')
* will retrieve the fourth grandchild, from the first child of the second
* element of the list;
* @param {Array} list The source list;
* @param {string|number} indexOrPath The index of the element inside list
* (number) or the path to reach the desired element (string). The slash "/"
* character is cosidered the path separator;
*/
function getItem(list, indexOrPath) {
if (Array.isArray(list)) {
let subpath = null;
let index = typeof indexOrPath === 'number' ? indexOrPath : -1;
if (typeof indexOrPath === 'string') {
const separator = indexOrPath.indexOf(SEPARATOR);
if (separator > 0) {
index = parseInt(indexOrPath.slice(0, separator), 10);
if (separator + 1 < indexOrPath.length) {
subpath = indexOrPath.slice(separator + 1, indexOrPath.length);
}
} else {
index = parseInt(indexOrPath, 10);
}
}
if (index >= 0 && index < list.length) {
const item = list[index];
if (isSublist(item)) {
if (subpath !== null) {
return getItem(item[1], subpath);
}
return item[0];
}
return item;
}
}
}
/**
* Pretty-print the provided hierarchical list;
* @param {Array} list The source list;
* @returns {string} The textual representation of the hierarchical list;
*/
function print(list) {
let text = '';
if (Array.isArray(list)) {
let prev = [];
forEachValue(list, function (...args) {
let prevLen = prev.length;
for (let i = 0, l = args.length; i < l; ++i) {
if (i < prevLen && args[i] === prev[i]) {
continue;
}
text += ' '.repeat(i) + args[i] + '\n';
}
prev = args;
});
}
return text;
}
/**
* Utils
*/
function forEachValue(list, callback) {
for (let i = 0, l = list.length; i < l; ++i) {
let item = list[i];
if (isSublist(item)) {
if (item[1].length > 0) {
forEachValue(item[1], callback.bind(null, item[0]));
continue;
}
item = item[0];
}
callback(item);
}
}
function addValuesToList(list, values) {
let value = values.shift();
let index = add(list, value);
if (index >= 0) {
if (values.length > 0) {
let sublist = list[index];
if (!isSublist(sublist)) {
sublist = toSublist(value);
list[index] = sublist;
}
return addValuesToList(sublist[1], values);
}
return true;
}
return false;
}
function add(list, value) {
let index = find(list, value);
if (index === -2) {
index = list.push(value) - 1;
}
return index;
}
function find(list, value) {
if (typeof value === 'string') {
for (let i = 0, l = list.length; i < l; ++i) {
let item = list[i];
if (item === value || (isSublist(item) && item[0] === value)) {
return i;
}
}
return -2;
}
return -1;
}
function isSublist(subject) {
return (
Array.isArray(subject) &&
subject.length === 2 &&
typeof subject[0] === 'string' &&
Array.isArray(subject[1])
);
}
function toSublist(value) {
return [value + '', []];
}
/**
* Exports
*/
const hierarchicalListUtils = { addToList, getItem, forEach, print };
export { addToList, getItem, forEach, print };
export default hierarchicalListUtils;

View File

@@ -0,0 +1,96 @@
import { addToList, forEach, getItem, print } from './hierarchicalListUtils';
describe('hierarchicalListUtils', function () {
let sharedList;
beforeEach(function () {
sharedList = [
['1.2.3.1', ['1.2.3.1.1', '1.2.3.1.2']],
'1.2.3.2',
['1.2.3.3', ['1.2.3.3.1', ['1.2.3.3.2', ['1.2.3.3.2.1', '1.2.3.3.2.2']]]],
];
});
describe('getItem', function () {
it('should retrieve elements from a list by index', function () {
expect(getItem(sharedList, 0)).toBe('1.2.3.1');
expect(getItem(sharedList, 1)).toBe('1.2.3.2');
expect(getItem(sharedList, 2)).toBe('1.2.3.3');
expect(getItem(sharedList, 3)).toBeUndefined();
});
it('should retrieve elements from a list by path', function () {
expect(getItem(sharedList, '0')).toBe('1.2.3.1');
expect(getItem(sharedList, '0/0')).toBe('1.2.3.1.1');
expect(getItem(sharedList, '0/1')).toBe('1.2.3.1.2');
expect(getItem(sharedList, '0/2')).toBeUndefined();
expect(getItem(sharedList, '1')).toBe('1.2.3.2');
expect(getItem(sharedList, '2')).toBe('1.2.3.3');
expect(getItem(sharedList, '2/0')).toBe('1.2.3.3.1');
expect(getItem(sharedList, '2/1')).toBe('1.2.3.3.2');
expect(getItem(sharedList, '2/2')).toBeUndefined();
expect(getItem(sharedList, '2/1/0')).toBe('1.2.3.3.2.1');
expect(getItem(sharedList, '2/1/1')).toBe('1.2.3.3.2.2');
expect(getItem(sharedList, '2/1/2')).toBeUndefined();
expect(getItem(sharedList, '3')).toBeUndefined();
});
});
describe('addToList', function () {
it('should support adding elements to a list hierarchically', function () {
const list = [];
addToList(list, '1.2.3.1', '1.2.3.1.1');
addToList(list, '1.2.3.1', '1.2.3.1.2');
addToList(list, '1.2.3.2');
addToList(list, '1.2.3.3', '1.2.3.3.1');
addToList(list, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1');
addToList(list, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2');
expect(list).toStrictEqual(sharedList);
});
it('should change leaf nodes into non-leaf nodes', function () {
const listw = [];
const listx = [['x.1', ['x.1.1', 'x.1.2']], 'x.2'];
const listy = [
['x.1', [['x.1.1', ['x.1.1.1']], 'x.1.2']],
['x.2', ['x.2.1']],
];
addToList(listw, 'x.1');
addToList(listw, 'x.1', 'x.1.1');
addToList(listw, 'x.1', 'x.1.2');
addToList(listw, 'x.2');
expect(listw).toStrictEqual(listx);
addToList(listw, 'x.2', 'x.2.1');
addToList(listw, 'x.1', 'x.1.1', 'x.1.1.1');
expect(listw).toStrictEqual(listy);
});
});
describe('forEach', function () {
it('should iterate through all leaf nodes of the tree', function () {
const fn = jest.fn();
forEach(sharedList, fn);
expect(fn).toHaveBeenCalledTimes(6);
expect(fn).nthCalledWith(1, '1.2.3.1', '1.2.3.1.1');
expect(fn).nthCalledWith(2, '1.2.3.1', '1.2.3.1.2');
expect(fn).nthCalledWith(3, '1.2.3.2');
expect(fn).nthCalledWith(4, '1.2.3.3', '1.2.3.3.1');
expect(fn).nthCalledWith(5, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1');
expect(fn).nthCalledWith(6, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2');
});
});
describe('print', function () {
it('should pretty-print the hierarchical list', function () {
expect(print(sharedList)).toBe(
'1.2.3.1\n' +
' 1.2.3.1.1\n' +
' 1.2.3.1.2\n' +
'1.2.3.2\n' +
'1.2.3.3\n' +
' 1.2.3.3.1\n' +
' 1.2.3.3.2\n' +
' 1.2.3.3.2.1\n' +
' 1.2.3.3.2.2\n'
);
});
});
});

View File

@@ -0,0 +1,8 @@
import Mousetrap from 'mousetrap';
import pausePlugin from './pausePlugin';
import recordPlugin from './recordPlugin';
recordPlugin(Mousetrap);
pausePlugin(Mousetrap);
export default Mousetrap;

View File

@@ -0,0 +1,32 @@
/**
* adds a pause and unpause method to Mousetrap
* this allows you to enable or disable keyboard shortcuts
* without having to reset Mousetrap and rebind everything
*
* https://github.com/ccampbell/mousetrap/blob/master/plugins/pause/mousetrap-pause.js
*/
export default function pausePlugin(Mousetrap) {
var _originalStopCallback = Mousetrap.prototype.stopCallback;
Mousetrap.prototype.stopCallback = function (e, element, combo) {
var self = this;
if (self.paused) {
return true;
}
return _originalStopCallback.call(self, e, element, combo);
};
Mousetrap.prototype.pause = function () {
var self = this;
self.paused = true;
};
Mousetrap.prototype.unpause = function () {
var self = this;
self.paused = false;
};
Mousetrap.init();
}

View File

@@ -0,0 +1,216 @@
/**
* This extension allows you to record a sequence using Mousetrap.
*
* @author Dan Tao <daniel.tao@gmail.com>
*/
export default function recordPlugin(Mousetrap, options = { timeout: 100 }) {
/**
* the sequence currently being recorded
*
* @type {Array}
*/
var _recordedSequence = [],
/**
* a callback to invoke after recording a sequence
*
* @type {Function|null}
*/
_recordedSequenceCallback = null,
/**
* a list of all of the keys currently held down
*
* @type {Array}
*/
_currentRecordedKeys = [],
/**
* temporary state where we remember if we've already captured a
* character key in the current combo
*
* @type {boolean}
*/
_recordedCharacterKey = false,
/**
* a handle for the timer of the current recording
*
* @type {null|number}
*/
_recordTimer = null,
/**
* the original handleKey method to override when Mousetrap.record() is
* called
*
* @type {Function}
*/
_origHandleKey = Mousetrap.prototype.handleKey;
/**
* handles a character key event
*
* @param {string} character
* @param {Array} modifiers
* @param {Event} e
* @returns void
*/
function _handleKey(character, modifiers, e) {
var self = this;
if (!self.recording) {
_origHandleKey.apply(self, arguments);
return;
}
// remember this character if we're currently recording a sequence
if (e.type == 'keydown') {
if (character.length === 1 && _recordedCharacterKey) {
_recordCurrentCombo();
}
for (let i = 0; i < modifiers.length; ++i) {
_recordKey(modifiers[i]);
}
_recordKey(character);
// once a key is released, all keys that were held down at the time
// count as a keypress
} else if (e.type == 'keyup' && _currentRecordedKeys.length > 0) {
_recordCurrentCombo();
}
}
/**
* marks a character key as held down while recording a sequence
*
* @param {string} key
* @returns void
*/
function _recordKey(key) {
// one-off implementation of Array.indexOf, since IE6-9 don't support it
for (let i = 0; i < _currentRecordedKeys.length; ++i) {
if (_currentRecordedKeys[i] === key) {
return;
}
}
_currentRecordedKeys.push(key);
if (key.length === 1) {
_recordedCharacterKey = true;
}
}
/**
* marks whatever key combination that's been recorded so far as finished
* and gets ready for the next combo
*
* @returns void
*/
function _recordCurrentCombo() {
_recordedSequence.push(_currentRecordedKeys);
_currentRecordedKeys = [];
_recordedCharacterKey = false;
_restartRecordTimer();
}
/**
* ensures each combo in a sequence is in a predictable order and formats
* key combos to be '+'-delimited
*
* modifies the sequence in-place
*
* @param {Array} sequence
* @returns void
*/
function _normalizeSequence(sequence) {
for (let i = 0; i < sequence.length; ++i) {
sequence[i].sort(function (x, y) {
// modifier keys always come first, in alphabetical order
if (x.length > 1 && y.length === 1) {
return -1;
} else if (x.length === 1 && y.length > 1) {
return 1;
}
// character keys come next (list should contain no duplicates,
// so no need for equality check)
return x > y ? 1 : -1;
});
sequence[i] = sequence[i].join('+');
}
}
/**
* finishes the current recording, passes the recorded sequence to the stored
* callback, and sets Mousetrap.handleKey back to its original function
*
* @returns void
*/
function _finishRecording() {
if (_recordedSequenceCallback) {
_normalizeSequence(_recordedSequence);
_recordedSequenceCallback(_recordedSequence);
}
// reset all recorded state
_recordedSequence = [];
_recordedSequenceCallback = null;
_currentRecordedKeys = [];
}
/**
* called to set a 1 second timeout on the current recording
*
* this is so after each key press in the sequence the recording will wait for
* 1 more second before executing the callback
*
* @returns void
*/
function _restartRecordTimer() {
clearTimeout(_recordTimer);
_recordTimer = setTimeout(_finishRecording, options.timeout);
}
/**
* records the next sequence and passes it to a callback once it's
* completed
*
* @param {Function} callback
* @returns void
*/
Mousetrap.prototype.record = function (callback) {
var self = this;
self.recording = true;
_recordedSequenceCallback = function () {
self.recording = false;
callback.apply(self, arguments);
};
};
/**
* stop recording
*
* @param {Function} callback
* @returns void
*/
Mousetrap.prototype.stopRecord = function () {
var self = this;
self.recording = false;
};
/**
* start recording
*
* @param {Function} callback
* @returns void
*/
Mousetrap.prototype.startRecording = function () {
var self = this;
self.recording = true;
};
Mousetrap.prototype.handleKey = function () {
var self = this;
_handleKey.apply(self, arguments);
};
Mousetrap.init();
}

View File

@@ -0,0 +1,12 @@
/**
* Removes the data loader scheme from the imageId
*
* @param {string} imageId Image ID
* @returns {string} imageId without the data loader scheme
* @memberof Cache
*/
export default function imageIdToURI(imageId) {
const colonIndex = imageId.indexOf(':');
return imageId.substring(colonIndex + 1);
}

View File

@@ -0,0 +1,52 @@
import * as utils from './index';
describe('Top level exports', () => {
test('should export the modules ', () => {
const expectedExports = [
'guid',
'ObjectPath',
'absoluteUrl',
'seriesSortCriteria',
'sortBy',
'sortStudy',
'sortBySeriesDate',
'sortStudyInstances',
'sortStudySeries',
'sortingCriteria',
'splitComma',
'getSplitParam',
'isLowPriorityModality',
'writeScript',
'debounce',
'downloadCSVReport',
'imageIdToURI',
'roundNumber',
'b64toBlob',
'sopClassDictionary',
'createStudyBrowserTabs',
'formatDate',
'formatTime',
'formatPN',
'generateAcceptHeader',
'isEqualWithin',
//'loadAndCacheDerivedDisplaySets',
'isDisplaySetReconstructable',
'isImage',
'urlUtil',
'makeDeferred',
'makeCancelable',
'hotkeys',
'Queue',
'isDicomUid',
'resolveObjectPath',
'hierarchicalListUtils',
'progressTrackingUtils',
'uuidv4',
'addAccessors',
].sort();
const exports = Object.keys(utils.default).sort();
expect(exports).toEqual(expectedExports);
});
});

View File

@@ -0,0 +1,122 @@
import ObjectPath from './objectPath';
import absoluteUrl from './absoluteUrl';
import guid from './guid';
import uuidv4 from './uuidv4';
import sortBy from './sortBy.js';
import writeScript from './writeScript.js';
import b64toBlob from './b64toBlob.js';
//import loadAndCacheDerivedDisplaySets from './loadAndCacheDerivedDisplaySets.js';
import urlUtil from './urlUtil';
import makeDeferred from './makeDeferred';
import makeCancelable from './makeCancelable';
import hotkeys from './hotkeys';
import Queue from './Queue';
import isDicomUid from './isDicomUid';
import formatDate from './formatDate';
import formatTime from './formatTime';
import formatPN from './formatPN';
import generateAcceptHeader from './generateAcceptHeader';
import resolveObjectPath from './resolveObjectPath';
import hierarchicalListUtils from './hierarchicalListUtils';
import progressTrackingUtils from './progressTrackingUtils';
import isLowPriorityModality from './isLowPriorityModality';
import { isImage } from './isImage';
import isDisplaySetReconstructable from './isDisplaySetReconstructable';
import sortInstancesByPosition from './sortInstancesByPosition';
import imageIdToURI from './imageIdToURI';
import debounce from './debounce';
import roundNumber from './roundNumber';
import downloadCSVReport from './downloadCSVReport';
import isEqualWithin from './isEqualWithin';
import addAccessors from './addAccessors';
import {
sortStudy,
sortStudySeries,
sortStudyInstances,
sortingCriteria,
seriesSortCriteria,
} from './sortStudy';
import { splitComma, getSplitParam } from './splitComma';
import { createStudyBrowserTabs } from './createStudyBrowserTabs';
import { sopClassDictionary } from './sopClassDictionary';
// Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager.
const utils = {
guid,
uuidv4,
ObjectPath,
absoluteUrl,
sortBy,
sortBySeriesDate: sortStudySeries,
sortStudy,
sortStudySeries,
sortStudyInstances,
sortingCriteria,
seriesSortCriteria,
writeScript,
formatDate,
formatTime,
formatPN,
b64toBlob,
urlUtil,
imageIdToURI,
//loadAndCacheDerivedDisplaySets,
makeDeferred,
makeCancelable,
hotkeys,
Queue,
isDicomUid,
isEqualWithin,
sopClassDictionary,
addAccessors,
resolveObjectPath,
hierarchicalListUtils,
progressTrackingUtils,
isLowPriorityModality,
isImage,
isDisplaySetReconstructable,
debounce,
roundNumber,
downloadCSVReport,
splitComma,
getSplitParam,
generateAcceptHeader,
createStudyBrowserTabs,
};
export {
guid,
ObjectPath,
absoluteUrl,
sortBy,
formatDate,
writeScript,
b64toBlob,
urlUtil,
//loadAndCacheDerivedDisplaySets,
makeDeferred,
makeCancelable,
hotkeys,
Queue,
isDicomUid,
isEqualWithin,
resolveObjectPath,
hierarchicalListUtils,
progressTrackingUtils,
isLowPriorityModality,
isImage,
isDisplaySetReconstructable,
sortInstancesByPosition,
imageIdToURI,
debounce,
roundNumber,
downloadCSVReport,
splitComma,
getSplitParam,
generateAcceptHeader,
createStudyBrowserTabs,
};
export default utils;

View File

@@ -0,0 +1,4 @@
export default function isDicomUid(subject) {
const regex = /^\d+(?:\.\d+)*$/;
return typeof subject === 'string' && regex.test(subject.trim());
}

View File

@@ -0,0 +1,16 @@
import isDicomUid from './isDicomUid';
describe('isDicomUid', function () {
it('should return true for valid DICOM UIDs', function () {
expect(isDicomUid('1')).toBe(true);
expect(isDicomUid('1.2')).toBe(true);
expect(isDicomUid('1.2.3')).toBe(true);
expect(isDicomUid('1.2.3.4')).toBe(true);
});
it('should return false for invalid DICOM UIDs', function () {
expect(isDicomUid('x')).toBe(false);
expect(isDicomUid('1.')).toBe(false);
expect(isDicomUid('1. 2')).toBe(false);
expect(isDicomUid('1.2.n.4')).toBe(false);
});
});

View File

@@ -0,0 +1,262 @@
import toNumber from './toNumber';
import sortInstancesByPosition from './sortInstancesByPosition';
// TODO: Is 10% a reasonable spacingTolerance for spacing?
const spacingTolerance = 0.2;
const iopTolerance = 0.01;
/**
* Checks if a series is reconstructable to a 3D volume.
*
* @param {Object[]} instances An array of `OHIFInstanceMetadata` objects.
*/
export default function isDisplaySetReconstructable(instances, appConfig) {
if (!instances.length) {
return { value: false };
}
const firstInstance = instances[0];
const isMultiframe = firstInstance.NumberOfFrames > 1;
if (appConfig) {
const rows = toNumber(firstInstance.Rows);
const columns = toNumber(firstInstance.Columns);
if (rows > appConfig.max3DTextureSize || columns > appConfig.max3DTextureSize) {
return { value: false };
}
}
// We used to check is reconstructable modalities here, but the logic is removed
// in favor of the calculation by metadata (orientation and positions)
// Can't reconstruct if we only have one image.
if (!isMultiframe && instances.length === 1) {
return { value: false };
}
// Can't reconstruct if all instances don't have the ImagePositionPatient.
if (!isMultiframe && !instances.every(instance => instance.ImagePositionPatient)) {
return { value: false };
}
const sortedInstances = sortInstancesByPosition(instances);
return isMultiframe ? processMultiframe(sortedInstances[0]) : processSingleframe(sortedInstances);
}
function hasPixelMeasurements(multiFrameInstance) {
const perFrameSequence = multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0];
const sharedSequence = multiFrameInstance.SharedFunctionalGroupsSequence;
return (
Boolean(perFrameSequence?.PixelMeasuresSequence) ||
Boolean(sharedSequence?.PixelMeasuresSequence) ||
Boolean(
multiFrameInstance.PixelSpacing &&
(multiFrameInstance.SliceThickness || multiFrameInstance.SpacingBetweenFrames)
)
);
}
function hasOrientation(multiFrameInstance) {
const sharedSequence = multiFrameInstance.SharedFunctionalGroupsSequence;
const perFrameSequence = multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0];
return (
Boolean(sharedSequence?.PlaneOrientationSequence) ||
Boolean(perFrameSequence?.PlaneOrientationSequence) ||
Boolean(
multiFrameInstance.ImageOrientationPatient ||
multiFrameInstance.DetectorInformationSequence?.[0]?.ImageOrientationPatient
)
);
}
function hasPosition(multiFrameInstance) {
const perFrameSequence = multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0];
return (
Boolean(perFrameSequence?.PlanePositionSequence) ||
Boolean(perFrameSequence?.CTPositionSequence) ||
Boolean(
multiFrameInstance.ImagePositionPatient ||
multiFrameInstance.DetectorInformationSequence?.[0]?.ImagePositionPatient
)
);
}
function isNMReconstructable(multiFrameInstance) {
const imageSubType = multiFrameInstance.ImageType?.[2];
return imageSubType === 'RECON TOMO' || imageSubType === 'RECON GATED TOMO';
}
function processMultiframe(multiFrameInstance) {
// If we don't have the PixelMeasuresSequence, then the pixel spacing and
// slice thickness isn't specified or is changing and we can't reconstruct
// the dataset.
if (!hasPixelMeasurements(multiFrameInstance)) {
return { value: false };
}
if (!hasOrientation(multiFrameInstance)) {
console.log('No image orientation information, not reconstructable');
return { value: false };
}
if (!hasPosition(multiFrameInstance)) {
console.log('No image position information, not reconstructable');
return { value: false };
}
if (multiFrameInstance.Modality.includes('NM') && !isNMReconstructable(multiFrameInstance)) {
return { value: false };
}
// TODO - check spacing consistency
return { value: true };
}
function processSingleframe(instances) {
const firstImage = instances[0];
const firstImageRows = toNumber(firstImage.Rows);
const firstImageColumns = toNumber(firstImage.Columns);
const firstImageSamplesPerPixel = toNumber(firstImage.SamplesPerPixel);
const firstImageOrientationPatient = toNumber(firstImage.ImageOrientationPatient);
const firstImagePositionPatient = toNumber(firstImage.ImagePositionPatient);
// Can't reconstruct if we:
// -- Have a different dimensions within a displaySet.
// -- Have a different number of components within a displaySet.
// -- Have different orientations within a displaySet.
for (let i = 1; i < instances.length; i++) {
const instance = instances[i];
const { Rows, Columns, SamplesPerPixel, ImageOrientationPatient } = instance;
const imageOrientationPatient = toNumber(ImageOrientationPatient);
if (
Rows !== firstImageRows ||
Columns !== firstImageColumns ||
SamplesPerPixel !== firstImageSamplesPerPixel ||
!_isSameOrientation(imageOrientationPatient, firstImageOrientationPatient)
) {
return { value: false };
}
}
let missingFrames = 0;
let averageSpacingBetweenFrames;
// Check if frame spacing is approximately equal within a spacingTolerance.
// If spacing is on a uniform grid but we are missing frames,
// Allow reconstruction, but pass back the number of missing frames.
if (instances.length > 2) {
const lastIpp = toNumber(instances[instances.length - 1].ImagePositionPatient);
// We can't reconstruct if we are missing ImagePositionPatient values
if (!firstImagePositionPatient || !lastIpp) {
return { value: false };
}
averageSpacingBetweenFrames =
_getPerpendicularDistance(firstImagePositionPatient, lastIpp) / (instances.length - 1);
let previousImagePositionPatient = firstImagePositionPatient;
for (let i = 1; i < instances.length; i++) {
const instance = instances[i];
// Todo: get metadata from OHIF.MetadataProvider
const imagePositionPatient = toNumber(instance.ImagePositionPatient);
const spacingBetweenFrames = _getPerpendicularDistance(
imagePositionPatient,
previousImagePositionPatient
);
const spacingIssue = _getSpacingIssue(spacingBetweenFrames, averageSpacingBetweenFrames);
if (spacingIssue) {
const issue = spacingIssue.issue;
if (issue === reconstructionIssues.MISSING_FRAMES) {
missingFrames += spacingIssue.missingFrames;
} else if (issue === reconstructionIssues.IRREGULAR_SPACING) {
return { value: false };
}
}
previousImagePositionPatient = imagePositionPatient;
}
}
return { value: true, averageSpacingBetweenFrames };
}
function _isSameOrientation(iop1, iop2) {
if (iop1 === undefined || iop2 === undefined) {
return;
}
return (
Math.abs(iop1[0] - iop2[0]) < iopTolerance &&
Math.abs(iop1[1] - iop2[1]) < iopTolerance &&
Math.abs(iop1[2] - iop2[2]) < iopTolerance &&
Math.abs(iop1[3] - iop2[3]) < iopTolerance &&
Math.abs(iop1[4] - iop2[4]) < iopTolerance &&
Math.abs(iop1[5] - iop2[5]) < iopTolerance
);
}
/**
* Checks for spacing issues.
*
* @param {number} spacing The spacing between two frames.
* @param {number} averageSpacing The average spacing between all frames.
*
* @returns {Object} An object containing the issue and extra information if necessary.
*/
function _getSpacingIssue(spacing, averageSpacing) {
const equalWithinTolerance =
Math.abs(spacing - averageSpacing) < averageSpacing * spacingTolerance;
if (equalWithinTolerance) {
return;
}
const multipleOfAverageSpacing = spacing / averageSpacing;
const numberOfSpacings = Math.round(multipleOfAverageSpacing);
const errorForEachSpacing =
Math.abs(spacing - numberOfSpacings * averageSpacing) / numberOfSpacings;
if (errorForEachSpacing < spacingTolerance * averageSpacing) {
return {
issue: reconstructionIssues.MISSING_FRAMES,
missingFrames: numberOfSpacings - 1,
};
}
return { issue: reconstructionIssues.IRREGULAR_SPACING };
}
function _getPerpendicularDistance(a, b) {
return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2));
}
const constructableModalities = ['MR', 'CT', 'PT', 'NM'];
const reconstructionIssues = {
MISSING_FRAMES: 'missingframes',
IRREGULAR_SPACING: 'irregularspacing',
};
export {
hasPixelMeasurements,
hasOrientation,
hasPosition,
isNMReconstructable,
_isSameOrientation,
_getSpacingIssue,
_getPerpendicularDistance,
reconstructionIssues,
constructableModalities,
};

View File

@@ -0,0 +1,27 @@
/**
* returns equal if the two arrays are identical within the
* given tolerance.
*
* @param v1 - The first array of values
* @param v2 - The second array of values.
* @param tolerance - The acceptable tolerance, the default is 0.00001
*
* @returns True if the two values are within the tolerance levels.
*/
export default function isEqualWithin(
v1: number[] | Float32Array,
v2: number[] | Float32Array,
tolerance = 1e-5
): boolean {
if (v1.length !== v2.length) {
return false;
}
for (let i = 0; i < v1.length; i++) {
if (Math.abs(v1[i] - v2[i]) > tolerance) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,65 @@
import { sopClassDictionary } from './sopClassDictionary';
const imagesTypes = [
sopClassDictionary.ComputedRadiographyImageStorage,
sopClassDictionary.DigitalXRayImageStorageForPresentation,
sopClassDictionary.DigitalXRayImageStorageForProcessing,
sopClassDictionary.DigitalMammographyXRayImageStorageForPresentation,
sopClassDictionary.DigitalMammographyXRayImageStorageForProcessing,
sopClassDictionary.DigitalIntraOralXRayImageStorageForPresentation,
sopClassDictionary.DigitalIntraOralXRayImageStorageForProcessing,
sopClassDictionary.CTImageStorage,
sopClassDictionary.EnhancedCTImageStorage,
sopClassDictionary.LegacyConvertedEnhancedCTImageStorage,
sopClassDictionary.UltrasoundMultiframeImageStorage,
sopClassDictionary.EnhancedUSVolumeStorage,
sopClassDictionary.MRImageStorage,
sopClassDictionary.EnhancedMRImageStorage,
sopClassDictionary.EnhancedMRColorImageStorage,
sopClassDictionary.LegacyConvertedEnhancedMRImageStorage,
sopClassDictionary.UltrasoundImageStorage,
sopClassDictionary.SecondaryCaptureImageStorage,
sopClassDictionary.MultiframeSingleBitSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeGrayscaleByteSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeGrayscaleWordSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeTrueColorSecondaryCaptureImageStorage,
sopClassDictionary.XRayAngiographicImageStorage,
sopClassDictionary.EnhancedXAImageStorage,
sopClassDictionary.XRayRadiofluoroscopicImageStorage,
sopClassDictionary.EnhancedXRFImageStorage,
sopClassDictionary.XRay3DAngiographicImageStorage,
sopClassDictionary.XRay3DCraniofacialImageStorage,
sopClassDictionary.BreastTomosynthesisImageStorage,
sopClassDictionary.BreastProjectionXRayImageStorageForPresentation,
sopClassDictionary.BreastProjectionXRayImageStorageForProcessing,
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForPresentation,
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForProcessing,
sopClassDictionary.NuclearMedicineImageStorage,
sopClassDictionary.VLEndoscopicImageStorage,
sopClassDictionary.VideoEndoscopicImageStorage,
sopClassDictionary.VLMicroscopicImageStorage,
sopClassDictionary.VideoMicroscopicImageStorage,
sopClassDictionary.VLSlideCoordinatesMicroscopicImageStorage,
sopClassDictionary.VLPhotographicImageStorage,
sopClassDictionary.VideoPhotographicImageStorage,
sopClassDictionary.OphthalmicPhotography8BitImageStorage,
sopClassDictionary.OphthalmicPhotography16BitImageStorage,
sopClassDictionary.OphthalmicTomographyImageStorage,
sopClassDictionary.VLWholeSlideMicroscopyImageStorage,
sopClassDictionary.PositronEmissionTomographyImageStorage,
sopClassDictionary.EnhancedPETImageStorage,
sopClassDictionary.LegacyConvertedEnhancedPETImageStorage,
sopClassDictionary.RTImageStorage,
];
/**
* Checks whether dicom files with specified SOP Class UID have image data
* @param {string} SOPClassUID - SOP Class UID to be checked
* @returns {boolean} - true if it has image data
*/
export const isImage = SOPClassUID => {
if (!SOPClassUID) {
return false;
}
return imagesTypes.indexOf(SOPClassUID) !== -1;
};

View File

@@ -0,0 +1,279 @@
import { sopClassDictionary } from './sopClassDictionary';
import { isImage } from './isImage';
describe('isImage', () => {
test('should return true when the image is of type sopClassDictionary.ComputedRadiographyImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.ComputedRadiographyImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalXRayImageStorageForPresentation', () => {
const isImageStatus = isImage(sopClassDictionary.DigitalXRayImageStorageForPresentation);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalXRayImageStorageForProcessing', () => {
const isImageStatus = isImage(sopClassDictionary.DigitalXRayImageStorageForProcessing);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalMammographyXRayImageStorageForPresentation', () => {
const isImageStatus = isImage(
sopClassDictionary.DigitalMammographyXRayImageStorageForPresentation
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalMammographyXRayImageStorageForProcessing', () => {
const isImageStatus = isImage(
sopClassDictionary.DigitalMammographyXRayImageStorageForProcessing
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalIntraOralXRayImageStorageForPresentation', () => {
const isImageStatus = isImage(
sopClassDictionary.DigitalIntraOralXRayImageStorageForPresentation
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.DigitalIntraOralXRayImageStorageForProcessing', () => {
const isImageStatus = isImage(sopClassDictionary.DigitalIntraOralXRayImageStorageForProcessing);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.CTImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.CTImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedCTImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedCTImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.LegacyConvertedEnhancedCTImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.LegacyConvertedEnhancedCTImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.UltrasoundMultiframeImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.UltrasoundMultiframeImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.MRImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.MRImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedMRImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedMRImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedMRColorImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedMRColorImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.LegacyConvertedEnhancedMRImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.LegacyConvertedEnhancedMRImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.UltrasoundImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.UltrasoundImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.SecondaryCaptureImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.SecondaryCaptureImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.MultiframeSingleBitSecondaryCaptureImageStorage', () => {
const isImageStatus = isImage(
sopClassDictionary.MultiframeSingleBitSecondaryCaptureImageStorage
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.MultiframeGrayscaleByteSecondaryCaptureImageStorage', () => {
const isImageStatus = isImage(
sopClassDictionary.MultiframeGrayscaleByteSecondaryCaptureImageStorage
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.MultiframeGrayscaleWordSecondaryCaptureImageStorage', () => {
const isImageStatus = isImage(
sopClassDictionary.MultiframeGrayscaleWordSecondaryCaptureImageStorage
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.MultiframeTrueColorSecondaryCaptureImageStorage', () => {
const isImageStatus = isImage(
sopClassDictionary.MultiframeTrueColorSecondaryCaptureImageStorage
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.XRayAngiographicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.XRayAngiographicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedXAImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedXAImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.XRayRadiofluoroscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.XRayRadiofluoroscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedXRFImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedXRFImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.XRay3DAngiographicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.XRay3DAngiographicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.XRay3DCraniofacialImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.XRay3DCraniofacialImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.BreastTomosynthesisImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.BreastTomosynthesisImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.BreastProjectionXRayImageStorageForPresentation', () => {
const isImageStatus = isImage(
sopClassDictionary.BreastProjectionXRayImageStorageForPresentation
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.BreastProjectionXRayImageStorageForProcessing', () => {
const isImageStatus = isImage(sopClassDictionary.BreastProjectionXRayImageStorageForProcessing);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForPresentation', () => {
const isImageStatus = isImage(
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForPresentation
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForProcessing', () => {
const isImageStatus = isImage(
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForProcessing
);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.NuclearMedicineImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.NuclearMedicineImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VLEndoscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VLEndoscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VideoEndoscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VideoEndoscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VLMicroscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VLMicroscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VideoMicroscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VideoMicroscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VLSlideCoordinatesMicroscopicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VLSlideCoordinatesMicroscopicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VLPhotographicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VLPhotographicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VideoPhotographicImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VideoPhotographicImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.OphthalmicPhotography8BitImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.OphthalmicPhotography8BitImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.OphthalmicPhotography16BitImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.OphthalmicPhotography16BitImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.OphthalmicTomographyImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.OphthalmicTomographyImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.VLWholeSlideMicroscopyImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.VLWholeSlideMicroscopyImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.PositronEmissionTomographyImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.PositronEmissionTomographyImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.EnhancedPETImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.EnhancedPETImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.LegacyConvertedEnhancedPETImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.LegacyConvertedEnhancedPETImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return true when the image is of type sopClassDictionary.RTImageStorage', () => {
const isImageStatus = isImage(sopClassDictionary.RTImageStorage);
expect(isImageStatus).toBe(true);
});
test('should return false when the image is of type sopClassDictionary.SpatialFiducialsStorage', () => {
const isImageStatus = isImage(sopClassDictionary.SpatialFiducialsStorage);
expect(isImageStatus).toBe(false);
});
test('should return false when the image is undefined', () => {
const isImageStatus = isImage(undefined);
expect(isImageStatus).toBe(false);
});
test('should return false when the image is null', () => {
const isImageStatus = isImage(null);
expect(isImageStatus).toBe(false);
});
});

View File

@@ -0,0 +1,5 @@
const LOW_PRIORITY_MODALITIES = Object.freeze(['SEG', 'KO', 'PR', 'SR', 'RTSTRUCT', 'RTDOSE', 'RTPLAN', 'RTRECORD', 'REG']);
export default function isLowPriorityModality(Modality) {
return LOW_PRIORITY_MODALITIES.includes(Modality);
}

View File

@@ -0,0 +1,23 @@
export default function makeCancelable(thenable) {
let isCanceled = false;
const promise = Promise.resolve(thenable).then(
function (result) {
if (isCanceled) {
throw Object.freeze({ isCanceled });
}
return result;
},
function (error) {
if (isCanceled) {
throw Object.freeze({ isCanceled, error });
}
throw error;
}
);
return Object.assign(Object.create(promise), {
then: promise.then.bind(promise),
cancel() {
isCanceled = true;
},
});
}

View File

@@ -0,0 +1,9 @@
export default function makeDeferred() {
let reject,
resolve,
promise = new Promise(function (res, rej) {
resolve = res;
reject = rej;
});
return Object.freeze({ promise, resolve, reject });
}

View File

@@ -0,0 +1,14 @@
import makeDeferred from './makeDeferred';
describe('makeDeferred', () => {
it('should provide a promise to be resolved externally', () => {
const deferred = makeDeferred();
setTimeout(() => void deferred.resolve('Yay!'));
return deferred.promise.then(result => void expect(result).toBe('Yay!'));
});
it('should provide a promise to be rejected externally', () => {
const deferred = makeDeferred();
setTimeout(() => void deferred.reject('Oops...'));
return deferred.promise.catch(error => void expect(error).toBe('Oops...'));
});
});

View File

@@ -0,0 +1,71 @@
/**
* Gets the palette color data for the specified tag - red/green/blue,
* either from the given UID or from the tag itself.
* Returns an array if the data is immediately available, or a promise
* which resolves to the data if the data needs to be loaded.
* Returns undefined if the palette isn't specified.
*
* @param {*} item containing the palette colour data and description
* @param {*} tag is the tag for the palette data
* @param {*} descriptorTag is the tag for the descriptor
* @returns Array view containing the palette data, or a promise to return one.
* Returns undefined if the palette data is absent.
*/
export default function fetchPaletteColorLookupTableData(item, tag, descriptorTag) {
const { PaletteColorLookupTableUID } = item;
const paletteData = item[tag];
if (paletteData === undefined && PaletteColorLookupTableUID === undefined) {
return;
}
// performance optimization - read UID and cache by UID
return _getPaletteColor(item[tag], item[descriptorTag]);
}
function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) {
const numLutEntries = lutDescriptor[0];
const bits = lutDescriptor[2];
if (!paletteColorLookupTableData) {
return undefined;
}
const arrayBufferToPaletteColorLUT = arraybuffer => {
const lut = [];
if (bits === 16) {
let j = 0;
for (let i = 0; i < numLutEntries; i++) {
lut[i] = (arraybuffer[j++] + arraybuffer[j++]) << 8;
}
} else {
for (let i = 0; i < numLutEntries; i++) {
lut[i] = arraybuffer[i];
}
}
return lut;
};
if (paletteColorLookupTableData.palette) {
return paletteColorLookupTableData.palette;
}
if (paletteColorLookupTableData.InlineBinary) {
try {
const arraybuffer = Uint8Array.from(atob(paletteColorLookupTableData.InlineBinary), c =>
c.charCodeAt(0)
);
return (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(arraybuffer));
} catch (e) {
console.log("Couldn't decode", paletteColorLookupTableData.InlineBinary, e);
return undefined;
}
}
if (paletteColorLookupTableData.retrieveBulkData) {
return paletteColorLookupTableData
.retrieveBulkData()
.then(val => (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(val)));
}
console.error(`No data found for ${paletteColorLookupTableData} palette`);
}

View File

@@ -0,0 +1,105 @@
import log from '../../log';
export default function getPixelSpacingInformation(instance) {
// See http://gdcm.sourceforge.net/wiki/index.php/Imager_Pixel_Spacing
// TODO: Add manual calibration
// TODO: Use ENUMS from dcmjs
const projectionRadiographSOPClassUIDs = [
'1.2.840.10008.5.1.4.1.1.1', // CR Image Storage
'1.2.840.10008.5.1.4.1.1.1.1', // Digital X-Ray Image Storage for Presentation
'1.2.840.10008.5.1.4.1.1.1.1.1', // Digital X-Ray Image Storage for Processing
'1.2.840.10008.5.1.4.1.1.1.2', // Digital Mammography X-Ray Image Storage for Presentation
'1.2.840.10008.5.1.4.1.1.1.2.1', // Digital Mammography X-Ray Image Storage for Processing
'1.2.840.10008.5.1.4.1.1.1.3', // Digital Intra oral X-Ray Image Storage for Presentation
'1.2.840.10008.5.1.4.1.1.1.3.1', // Digital Intra oral X-Ray Image Storage for Processing
'1.2.840.10008.5.1.4.1.1.12.1', // X-Ray Angiographic Image Storage
'1.2.840.10008.5.1.4.1.1.12.1.1', // Enhanced XA Image Storage
'1.2.840.10008.5.1.4.1.1.12.2', // X-Ray Radiofluoroscopic Image Storage
'1.2.840.10008.5.1.4.1.1.12.2.1', // Enhanced XRF Image Storage
'1.2.840.10008.5.1.4.1.1.12.3', // X-Ray Angiographic Bi-plane Image Storage Retired
];
const {
PixelSpacing,
ImagerPixelSpacing,
SOPClassUID,
PixelSpacingCalibrationType,
PixelSpacingCalibrationDescription,
EstimatedRadiographicMagnificationFactor,
} = instance;
const isProjection = projectionRadiographSOPClassUIDs.includes(SOPClassUID);
const TYPES = {
NOT_APPLICABLE: 'NOT_APPLICABLE',
UNKNOWN: 'UNKNOWN',
CALIBRATED: 'CALIBRATED',
DETECTOR: 'DETECTOR',
};
if (isProjection && !ImagerPixelSpacing) {
// If only Pixel Spacing is present, and this is a projection radiograph,
// PixelSpacing should be used, but the user should be informed that
// what it means is unknown
return {
PixelSpacing,
type: TYPES.UNKNOWN,
isProjection,
};
} else if (PixelSpacing && ImagerPixelSpacing && PixelSpacing === ImagerPixelSpacing) {
// If Imager Pixel Spacing and Pixel Spacing are present and they have the same values,
// then the user should be informed that the measurements are at the detector plane
return {
PixelSpacing,
type: TYPES.DETECTOR,
isProjection,
};
} else if (PixelSpacing && ImagerPixelSpacing && PixelSpacing !== ImagerPixelSpacing) {
// If Imager Pixel Spacing and Pixel Spacing are present and they have different values,
// then the user should be informed that these are "calibrated"
// (in some unknown manner if Pixel Spacing Calibration Type and/or
// Pixel Spacing Calibration Description are absent)
return {
PixelSpacing,
type: TYPES.CALIBRATED,
isProjection,
PixelSpacingCalibrationType,
PixelSpacingCalibrationDescription,
};
} else if (!PixelSpacing && ImagerPixelSpacing) {
let CorrectedImagerPixelSpacing = ImagerPixelSpacing;
if (EstimatedRadiographicMagnificationFactor) {
// Note that in IHE Mammo profile compliant displays, the value of Imager Pixel Spacing is required to be corrected by
// Estimated Radiographic Magnification Factor and the user informed of that.
// TODO: should this correction be done before all of this logic?
CorrectedImagerPixelSpacing = ImagerPixelSpacing.map(
pixelSpacing => pixelSpacing / EstimatedRadiographicMagnificationFactor
);
} else {
if (!instance._loggedSpacingMessage) {
log.info(
'EstimatedRadiographicMagnificationFactor was not present. Unable to correct ImagerPixelSpacing.'
);
instance._loggedSpacingMessage = true;
}
}
return {
PixelSpacing: CorrectedImagerPixelSpacing,
isProjection,
};
} else if (isProjection === false && !ImagerPixelSpacing) {
// If only Pixel Spacing is present, and this is not a projection radiograph,
// we can stop here
return {
PixelSpacing,
type: TYPES.NOT_APPLICABLE,
isProjection,
};
}
log.info(
'Unknown combination of PixelSpacing and ImagerPixelSpacing identified. Unable to determine spacing.'
);
}

View File

@@ -0,0 +1,12 @@
export default function unpackOverlay(arrayBuffer) {
const bitArray = new Uint8Array(arrayBuffer);
const byteArray = new Uint8Array(8 * bitArray.length);
for (let byteIndex = 0; byteIndex < byteArray.length; byteIndex++) {
const bitIndex = byteIndex % 8;
const bitByteIndex = Math.floor(byteIndex / 8);
byteArray[byteIndex] = 1 * ((bitArray[bitByteIndex] & (1 << bitIndex)) >> bitIndex);
}
return byteArray;
}

View File

@@ -0,0 +1,96 @@
export class ObjectPath {
/**
* Set an object property based on "path" (namespace) supplied creating
* ... intermediary objects if they do not exist.
* @param object {Object} An object where the properties specified on path should be set.
* @param path {String} A string representing the property to be set, e.g. "user.study.series.timepoint".
* @param value {Any} The value of the property that will be set.
* @return {Boolean} Returns "true" on success, "false" if any intermediate component of the supplied path
* ... is not a valid Object, in which case the property cannot be set. No exceptions are thrown.
*/
static set(object, path, value) {
let components = ObjectPath.getPathComponents(path),
length = components !== null ? components.length : 0,
result = false;
if (length > 0 && ObjectPath.isValidObject(object)) {
let i = 0,
last = length - 1,
currentObject = object;
while (i < last) {
let field = components[i];
if (field in currentObject) {
if (!ObjectPath.isValidObject(currentObject[field])) {
break;
}
} else {
currentObject[field] = {};
}
currentObject = currentObject[field];
i++;
}
if (i === last) {
currentObject[components[last]] = value;
result = true;
}
}
return result;
}
/**
* Get an object property based on "path" (namespace) supplied traversing the object
* ... tree as necessary.
* @param object {Object} An object where the properties specified might exist.
* @param path {String} A string representing the property to be searched for, e.g. "user.study.series.timepoint".
* @return {Any} The value of the property if found. By default, returns the special type "undefined".
*/
static get(object, path) {
let found, // undefined by default
components = ObjectPath.getPathComponents(path),
length = components !== null ? components.length : 0;
if (length > 0 && ObjectPath.isValidObject(object)) {
let i = 0,
last = length - 1,
currentObject = object;
while (i < last) {
let field = components[i];
const isValid = ObjectPath.isValidObject(currentObject[field]);
if (field in currentObject && isValid) {
currentObject = currentObject[field];
i++;
} else {
break;
}
}
if (i === last && components[last] in currentObject) {
found = currentObject[components[last]];
}
}
return found;
}
/**
* Check if the supplied argument is a real JavaScript Object instance.
* @param object {Any} The subject to be tested.
* @return {Boolean} Returns "true" if the object is a real Object instance and "false" otherwise.
*/
static isValidObject(object) {
return typeof object === 'object' && object !== null && object instanceof Object;
}
static getPathComponents(path) {
return typeof path === 'string' ? path.split('.') : null;
}
}
export default ObjectPath;

View File

@@ -0,0 +1,99 @@
import objectPath from './objectPath';
describe('objectPath', () => {
test('should return false when the supplied argument is not a real JavaScript Object instance such as undefined', () => {
expect(objectPath.isValidObject(undefined)).toBe(false);
});
test('should return false when the supplied argument is not a real JavaScript Object instance such as null', () => {
expect(objectPath.isValidObject(null)).toBe(false);
});
test('should return true when the supplied argument is a real JavaScript Object instance', () => {
expect(objectPath.isValidObject({})).toBe(true);
});
test('should return [path1, path2, path3] when the path is path1.path2.path3', () => {
const path = 'path1.path2.path3';
const expectedPathComponents = objectPath.getPathComponents(path);
expect(expectedPathComponents).toEqual(['path1', 'path2', 'path3']);
});
test('should return null when the path is not a string', () => {
const path = 20;
const expectedPathComponents = objectPath.getPathComponents(path);
expect(expectedPathComponents).toEqual(null);
});
test('should return [path1path2path3] when the path is path1path2path3', () => {
const path = 'path1path2path3';
const expectedPathComponents = objectPath.getPathComponents(path);
expect(expectedPathComponents).toEqual(['path1path2path3']);
});
test('should return the property obj.myProperty when the object contains myProperty', () => {
const searchObject = {
obj: {
myProperty: 'MOCK_VALUE',
},
};
const path = 'obj.myProperty';
const expectedPathComponents = objectPath.get(searchObject, path);
expect(expectedPathComponents).toEqual(searchObject.obj.myProperty);
});
test('should return undefined when the object does not contain a property', () => {
const searchObject = {
obj: {
myProperty: 'MOCK_VALUE',
},
};
const path = 'obj.unknownProperty';
const expectedPathComponents = objectPath.get(searchObject, path);
expect(expectedPathComponents).toEqual(undefined);
});
test('should return undefined when the object is not a valid object', () => {
const searchObject = undefined;
const path = 'obj.unknownProperty';
const expectedPathComponents = objectPath.get(searchObject, path);
expect(expectedPathComponents).toEqual(undefined);
});
test('should return undefined when the inner object is not a valid object', () => {
const searchObject = {
obj: {
myProperty: null,
},
};
const path = 'obj.unknownProperty';
const expectedPathComponents = objectPath.get(searchObject, path);
expect(expectedPathComponents).toEqual(undefined);
});
test('should set the property obj.myProperty when the object does not contain myProperty', () => {
const searchObject = {
obj: {
anyProperty: 'MOCK_VALUE',
},
};
const newValue = 'NEW_VALUE';
const path = 'obj.myProperty';
const output = objectPath.set(searchObject, path, newValue);
expect(output).toBe(true);
expect(searchObject.obj.myProperty).toEqual(newValue);
});
test('should return false when the object which is being set is not in a valid path', () => {
const searchObject = {
obj: {
myProperty: 'MOCK_VALUE',
},
};
const path = undefined;
const newValue = 'NEW_VALUE';
const output = objectPath.set(searchObject, path, newValue);
expect(output).toEqual(false);
});
});

View File

@@ -0,0 +1,326 @@
import makeDeferred from './makeDeferred';
/**
* Constants
*/
const TYPE = Symbol('Type');
const TASK = Symbol('Task');
const LIST = Symbol('List');
/**
* Public Methods
*/
/**
* Creates an instance of a task list
* @returns {Object} A task list object
*/
function createList() {
return objectWithType(LIST, {
head: null,
named: Object.create(null),
observers: [],
});
}
/**
* Checks if the given argument is a List instance
* @param {any} subject The value to be tested
* @returns {boolean} true if a valid List instance is given, false otherwise
*/
function isList(subject) {
return isOfType(LIST, subject);
}
/**
* Creates an instance of a task
* @param {Object} list The List instance related to this task
* @param {Object} next The next Task instance to link to
* @returns {Object} A task object
*/
function createTask(list, next) {
return objectWithType(TASK, {
list: isList(list) ? list : null,
next: isTask(next) ? next : null,
failed: false,
awaiting: null,
progress: 0.0,
});
}
/**
* Checks if the given argument is a Task instance
* @param {any} subject The value to be tested
* @returns {boolean} true if a valid Task instance is given, false otherwise
*/
function isTask(subject) {
return isOfType(TASK, subject);
}
/**
* Appends a new Task to the given List instance and notifies the list observers
* @param {Object} list A List instance
* @returns {Object} The new Task instance appended to the List or null if the
* given List instanc is not valid
*/
function increaseList(list) {
if (isList(list)) {
const task = createTask(list, list.head);
list.head = task;
notify(list, getOverallProgress(list));
return task;
}
return null;
}
/**
* Updates the internal progress value of the given Task instance and notifies
* the observers of the associated list.
* @param {Object} task The Task instance to be updated
* @param {number} value A number between 0 (inclusive) and 1 (exclusive)
* indicating the progress of the task;
* @returns {void} Nothing is returned
*/
function update(task, value) {
if (isTask(task) && isValidProgress(value) && value < 1.0) {
if (task.progress !== value) {
task.progress = value;
if (isList(task.list)) {
notify(task.list, getOverallProgress(task.list));
}
}
}
}
/**
* Sets a Task instance as finished (progress = 1.0), freezes it in order to
* prevent further modifications and notifies the observers of the associated
* list.
* @param {Object} task The Task instance to be finalized
* @returns {void} Nothing is returned
*/
function finish(task) {
if (isTask(task)) {
task.progress = 1.0;
task.awaiting = null;
Object.freeze(task);
if (isList(task.list)) {
notify(task.list, getOverallProgress(task.list));
}
}
}
/**
* Generate a summarized snapshot of the current status of the given task List
* @param {Object} list The List instance to be scanned
* @returns {Object} An object representing the summarized status of the list
*/
function getOverallProgress(list) {
const status = createStatus();
if (isList(list)) {
let task = list.head;
while (isTask(task)) {
status.total++;
if (isValidProgress(task.progress)) {
status.partial += task.progress;
if (task.progress === 1.0 && task.failed) {
status.failures++;
}
}
task = task.next;
}
}
if (status.total > 0) {
status.progress = status.partial / status.total;
}
return Object.freeze(status);
}
/**
* Adds a Task instance to the given list that waits on a given "thenable". When
* the thenable resolves the "finish" method is called on the newly created
* instance thus notifying the observers of the list.
* @param {Object} list The List instance to which the new task will be added
* @param {Object|Promise} thenable The thenable to be waited on
* @returns {Object} A reference to the newly created Task;
*/
function waitOn(list, thenable) {
const task = increaseList(list);
if (isTask(task)) {
task.awaiting = Promise.resolve(thenable).then(
function () {
finish(task);
},
function () {
task.failed = true;
finish(task);
}
);
return task;
}
return null;
}
/**
* Adds a Task instance to the given list using a deferred (a Promise that can
* be externally resolved) notifying the observers of the list.
* @param {Object} list The List instance to which the new task will be added
* @returns {Object} An object with references to the created deferred and task
*/
function addDeferred(list) {
const deferred = makeDeferred();
const task = waitOn(list, deferred.promise);
return Object.freeze({
deferred,
task,
});
}
/**
* Assigns a name to a specific task of the list
* @param {Object} list The List instance whose task will be named
* @param {Object} task The specified Task instance
* @param {string} name The name of the task
* @returns {boolean} Returns true on success, false otherwise
*/
function setTaskName(list, task, name) {
if (
contains(list, task) &&
list.named !== null &&
typeof list.named === 'object' &&
typeof name === 'string'
) {
list.named[name] = task;
return true;
}
return false;
}
/**
* Retrieves a task by name
* @param {Object} list The List instance whose task will be retrieved
* @param {string} name The name of the task to be retrieved
* @returns {Object} The Task instance or null if not found
*/
function getTaskByName(list, name) {
if (
isList(list) &&
list.named !== null &&
typeof list.named === 'object' &&
typeof name === 'string'
) {
const task = list.named[name];
if (isTask(task)) {
return task;
}
}
return null;
}
/**
* Adds an observer (callback function) to a given List instance
* @param {Object} list The List instance to which the observer will be appended
* @param {Function} observer The observer (function) that will be executed
* every time a change happens within the list
* @returns {boolean} Returns true on success and false otherwise
*/
function addObserver(list, observer) {
if (isList(list) && Array.isArray(list.observers) && typeof observer === 'function') {
list.observers.push(observer);
return true;
}
return false;
}
/**
* Removes an observer (callback function) from a given List instance
* @param {Object} list The instance List from which the observer will removed
* @param {Function} observer The observer function to be removed
* @returns {boolean} Returns true on success and false otherwise
*/
function removeObserver(list, observer) {
if (isList(list) && Array.isArray(list.observers) && list.observers.length > 0) {
const index = list.observers.indexOf(observer);
if (index >= 0) {
list.observers.splice(index, 1);
return true;
}
}
return false;
}
/**
* Utils
*/
function createStatus() {
return Object.seal({
total: 0,
partial: 0.0,
progress: 0.0,
failures: 0,
});
}
function objectWithType(type, object) {
return Object.seal(Object.defineProperty(object, TYPE, { value: type }));
}
function isOfType(type, subject) {
return subject !== null && typeof subject === 'object' && subject[TYPE] === type;
}
function isValidProgress(value) {
return typeof value === 'number' && value >= 0.0 && value <= 1.0;
}
function contains(list, task) {
if (isList(list) && isTask(task)) {
let item = list.head;
while (isTask(item)) {
if (item === task) {
return true;
}
item = item.next;
}
}
return false;
}
function notify(list, data) {
if (isList(list) && Array.isArray(list.observers) && list.observers.length > 0) {
list.observers.slice().forEach(function (observer) {
if (typeof observer === 'function') {
try {
observer(data, list);
} catch (e) {
/* Oops! */
}
}
});
}
}
/**
* Exports
*/
const progressTrackingUtils = {
createList,
isList,
createTask,
isTask,
increaseList,
update,
finish,
getOverallProgress,
waitOn,
addDeferred,
setTaskName,
getTaskByName,
addObserver,
removeObserver,
};
export default progressTrackingUtils;

View File

@@ -0,0 +1,171 @@
import utils from './progressTrackingUtils';
describe('progressTrackingUtils', () => {
describe('Creation of lists of tasks to be tracked', () => {
it('should support creation of task lists', () => {
expect(utils.createList()).toBeInstanceOf(Object);
});
it('should support validation of task lists', () => {
const list = utils.createList();
expect(utils.isList(list)).toBe(true);
expect(utils.isList(JSON.parse(JSON.stringify(list)))).toBe(false);
});
});
describe('Usage of lists of tasks to be tracked', () => {
let context;
// Mock for download
function fakeRequest(callback) {
return new Promise(resolve => {
let progress = 0.0;
setTimeout(function step() {
if (progress < 1.0) {
progress += 1 / 4;
callback(progress);
setTimeout(step);
return;
}
resolve(true);
});
});
}
beforeEach(() => {
const list = utils.createList();
const observer = jest.fn();
utils.addObserver(list, observer);
context = { list, observer };
});
it('should call observer twice for each task', () => {
const { list, observer } = context;
const promises = [Promise.resolve('A'), Promise.resolve('B'), Promise.resolve('C')];
promises.forEach(promise => void utils.waitOn(list, promise));
return Promise.all(promises).then(() => {
expect(observer).toBeCalledTimes(6);
[
{
failures: 0,
partial: 0,
progress: 0,
total: 1,
},
{
failures: 0,
partial: 0,
progress: 0,
total: 2,
},
{
failures: 0,
partial: 0,
progress: 0,
total: 3,
},
{
failures: 0,
partial: 1.0,
progress: 1 / 3,
total: 3,
},
{
failures: 0,
partial: 2.0,
progress: 2 / 3,
total: 3,
},
{
failures: 0,
partial: 3.0,
progress: 1.0,
total: 3,
},
].forEach((item, i) => {
const result = expect.objectContaining(item);
expect(observer).nthCalledWith(i + 1, result, list);
});
expect(utils.getOverallProgress(list)).toStrictEqual({
failures: 0,
partial: 3.0,
progress: 1.0,
total: 3,
});
});
});
it('should support tasks with internal progress updates', () => {
const { list, observer } = context;
const download = utils.addDeferred(list);
const processing = download.deferred.promise.then(result => result);
const update = jest.fn(p => void utils.update(download.task, p));
download.deferred.resolve(fakeRequest(update));
utils.waitOn(list, processing);
return processing.then(() => {
expect(update).toBeCalledTimes(4);
[0.25, 0.5, 0.75, 1.0].forEach(
(value, i) => void expect(update).nthCalledWith(i + 1, value)
);
expect(observer).toBeCalledTimes(7);
[
{
failures: 0,
partial: 0,
progress: 0,
total: 1,
},
{
failures: 0,
partial: 0,
progress: 0,
total: 2,
},
{
failures: 0,
partial: 0.25,
progress: 0.125,
total: 2,
},
{
failures: 0,
partial: 0.5,
progress: 0.25,
total: 2,
},
{
failures: 0,
partial: 0.75,
progress: 0.375,
total: 2,
},
{
failures: 0,
partial: 1.0,
progress: 0.5,
total: 2,
},
{
failures: 0,
partial: 2.0,
progress: 1.0,
total: 2,
},
].forEach((item, i) => {
const result = expect.objectContaining(item);
expect(observer).nthCalledWith(i + 1, result, list);
});
});
});
});
describe('Naming of specific tasks', () => {
it('should support naming specific tasks', () => {
const list = utils.createList();
const tasks = [utils.increaseList(list), utils.increaseList(list)];
expect(utils.setTaskName(list, tasks[0], 'firstTask')).toBe(true);
expect(utils.setTaskName(list, tasks[1], 'secondTask')).toBe(true);
expect(utils.getTaskByName(list, 'secondTask')).toBe(tasks[1]);
expect(utils.getTaskByName(list, 'firstTask')).toBe(tasks[0]);
});
});
});

View File

@@ -0,0 +1,3 @@
const reconstructableModalities = ['MR', 'CT', 'PT', 'NM'];
export default reconstructableModalities;

View File

@@ -0,0 +1,15 @@
export default function resolveObjectPath(root, path, defaultValue) {
if (root !== null && typeof root === 'object' && typeof path === 'string') {
let value,
separator = path.indexOf('.');
if (separator >= 0) {
return resolveObjectPath(
root[path.slice(0, separator)],
path.slice(separator + 1, path.length),
defaultValue
);
}
value = root[path];
return value === undefined && defaultValue !== undefined ? defaultValue : value;
}
}

View File

@@ -0,0 +1,35 @@
import resolveObjectPath from './resolveObjectPath';
describe('resolveObjectPath', function () {
let config;
beforeEach(function () {
config = {
active: {
user: {
name: {
first: 'John',
last: 'Doe',
},
},
servers: [
{
ipv4: '10.0.0.1',
},
],
},
};
});
it('should safely return deeply nested values from an object', function () {
expect(resolveObjectPath(config, 'active.user.name.first')).toBe('John');
expect(resolveObjectPath(config, 'active.user.name.last')).toBe('Doe');
expect(resolveObjectPath(config, 'active.servers.0.ipv4')).toBe('10.0.0.1');
});
it('should silently return undefined when intermediate values are not valid objects', function () {
expect(resolveObjectPath(config, 'active.usr.name.first')).toBeUndefined();
expect(resolveObjectPath(config, 'active.name.last')).toBeUndefined();
expect(resolveObjectPath(config, 'active.servers.7.ipv4')).toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
/**
* Truncates decimal points to that there is at least 1+precision significant
* digits.
*
* For example, with the default precision 2 (3 significant digits)
* * Values larger than 100 show no information after the decimal point
* * Values between 10 and 99 show 1 decimal point
* * Values between 1 and 9 show 2 decimal points
*
* @param value - to return a fixed measurement value from
* @param precision - defining how many digits after 1..9 are desired
*/
function roundNumber(value, precision = 2) {
if (Array.isArray(value)) {
return value.map(v => roundNumber(v, precision)).join(', ');
}
if (value === undefined || value === null || value === '') {
return 'NaN';
}
value = Number(value);
const absValue = Math.abs(value);
if (absValue < 0.0001) {
return `${value}`;
}
const fixedPrecision =
absValue >= 100
? precision - 2
: absValue >= 10
? precision - 1
: absValue >= 1
? precision
: absValue >= 0.1
? precision + 1
: absValue >= 0.01
? precision + 2
: absValue >= 0.001
? precision + 3
: precision + 4;
return value.toFixed(fixedPrecision);
}
export default roundNumber;

View File

@@ -0,0 +1,119 @@
// TODO: Deprecate since we have the same thing in dcmjs?
export const sopClassDictionary = {
ComputedRadiographyImageStorage: '1.2.840.10008.5.1.4.1.1.1',
DigitalXRayImageStorageForPresentation: '1.2.840.10008.5.1.4.1.1.1.1',
DigitalXRayImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.1.1.1',
DigitalMammographyXRayImageStorageForPresentation: '1.2.840.10008.5.1.4.1.1.1.2',
DigitalMammographyXRayImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.1.2.1',
DigitalIntraOralXRayImageStorageForPresentation: '1.2.840.10008.5.1.4.1.1.1.3',
DigitalIntraOralXRayImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.1.3.1',
CTImageStorage: '1.2.840.10008.5.1.4.1.1.2',
EnhancedCTImageStorage: '1.2.840.10008.5.1.4.1.1.2.1',
LegacyConvertedEnhancedCTImageStorage: '1.2.840.10008.5.1.4.1.1.2.2',
UltrasoundMultiframeImageStorage: '1.2.840.10008.5.1.4.1.1.3.1',
MRImageStorage: '1.2.840.10008.5.1.4.1.1.4',
EnhancedMRImageStorage: '1.2.840.10008.5.1.4.1.1.4.1',
MRSpectroscopyStorage: '1.2.840.10008.5.1.4.1.1.4.2',
EnhancedMRColorImageStorage: '1.2.840.10008.5.1.4.1.1.4.3',
LegacyConvertedEnhancedMRImageStorage: '1.2.840.10008.5.1.4.1.1.4.4',
UltrasoundImageStorage: '1.2.840.10008.5.1.4.1.1.6.1',
EnhancedUSVolumeStorage: '1.2.840.10008.5.1.4.1.1.6.2',
SecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7',
MultiframeSingleBitSecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7.1',
MultiframeGrayscaleByteSecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7.2',
MultiframeGrayscaleWordSecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7.3',
MultiframeTrueColorSecondaryCaptureImageStorage: '1.2.840.10008.5.1.4.1.1.7.4',
Sop12LeadECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.1',
GeneralECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.2',
AmbulatoryECGWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.1.3',
HemodynamicWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.2.1',
CardiacElectrophysiologyWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.3.1',
BasicVoiceAudioWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.4.1',
GeneralAudioWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.4.2',
ArterialPulseWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.5.1',
RespiratoryWaveformStorage: '1.2.840.10008.5.1.4.1.1.9.6.1',
GrayscaleSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.1',
ColorSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.2',
PseudoColorSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.3',
BlendingSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.4',
XAXRFGrayscaleSoftcopyPresentationStateStorage: '1.2.840.10008.5.1.4.1.1.11.5',
XRayAngiographicImageStorage: '1.2.840.10008.5.1.4.1.1.12.1',
EnhancedXAImageStorage: '1.2.840.10008.5.1.4.1.1.12.1.1',
XRayRadiofluoroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.12.2',
EnhancedXRFImageStorage: '1.2.840.10008.5.1.4.1.1.12.2.1',
XRay3DAngiographicImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.1',
XRay3DCraniofacialImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.2',
BreastTomosynthesisImageStorage: '1.2.840.10008.5.1.4.1.1.13.1.3',
BreastProjectionXRayImageStorageForPresentation: '1.2.840.10008.5.1.4.1.1.13.1.4',
BreastProjectionXRayImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.13.1.5',
IntravascularOpticalCoherenceTomographyImageStorageForPresentation:
'1.2.840.10008.5.1.4.1.1.14.1',
IntravascularOpticalCoherenceTomographyImageStorageForProcessing: '1.2.840.10008.5.1.4.1.1.14.2',
NuclearMedicineImageStorage: '1.2.840.10008.5.1.4.1.1.20',
RawDataStorage: '1.2.840.10008.5.1.4.1.1.66',
SpatialRegistrationStorage: '1.2.840.10008.5.1.4.1.1.66.1',
SpatialFiducialsStorage: '1.2.840.10008.5.1.4.1.1.66.2',
DeformableSpatialRegistrationStorage: '1.2.840.10008.5.1.4.1.1.66.3',
SegmentationStorage: '1.2.840.10008.5.1.4.1.1.66.4',
SurfaceSegmentationStorage: '1.2.840.10008.5.1.4.1.1.66.5',
RealWorldValueMappingStorage: '1.2.840.10008.5.1.4.1.1.67',
SurfaceScanMeshStorage: '1.2.840.10008.5.1.4.1.1.68.1',
SurfaceScanPointCloudStorage: '1.2.840.10008.5.1.4.1.1.68.2',
VLEndoscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.1',
VideoEndoscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.1.1',
VLMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.2',
VideoMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.2.1',
VLSlideCoordinatesMicroscopicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.3',
VLPhotographicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.4',
VideoPhotographicImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.4.1',
OphthalmicPhotography8BitImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.1',
OphthalmicPhotography16BitImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.2',
StereometricRelationshipStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.3',
OphthalmicTomographyImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.5.4',
VLWholeSlideMicroscopyImageStorage: '1.2.840.10008.5.1.4.1.1.77.1.6',
LensometryMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.1',
AutorefractionMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.2',
KeratometryMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.3',
SubjectiveRefractionMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.4',
VisualAcuityMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.5',
SpectaclePrescriptionReportStorage: '1.2.840.10008.5.1.4.1.1.78.6',
OphthalmicAxialMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.78.7',
IntraocularLensCalculationsStorage: '1.2.840.10008.5.1.4.1.1.78.8',
MacularGridThicknessandVolumeReport: '1.2.840.10008.5.1.4.1.1.79.1',
OphthalmicVisualFieldStaticPerimetryMeasurementsStorage: '1.2.840.10008.5.1.4.1.1.80.1',
OphthalmicThicknessMapStorage: '1.2.840.10008.5.1.4.1.1.81.1',
CornealTopographyMapStorage: '1.2.840.10008.5.1.4.1.1.82.1',
BasicTextSR: '1.2.840.10008.5.1.4.1.1.88.11',
EnhancedSR: '1.2.840.10008.5.1.4.1.1.88.22',
ComprehensiveSR: '1.2.840.10008.5.1.4.1.1.88.33',
Comprehensive3DSR: '1.2.840.10008.5.1.4.1.1.88.34',
ProcedureLog: '1.2.840.10008.5.1.4.1.1.88.40',
MammographyCADSR: '1.2.840.10008.5.1.4.1.1.88.50',
KeyObjectSelection: '1.2.840.10008.5.1.4.1.1.88.59',
ChestCADSR: '1.2.840.10008.5.1.4.1.1.88.65',
XRayRadiationDoseSR: '1.2.840.10008.5.1.4.1.1.88.67',
RadiopharmaceuticalRadiationDoseSR: '1.2.840.10008.5.1.4.1.1.88.68',
ColonCADSR: '1.2.840.10008.5.1.4.1.1.88.69',
ImplantationPlanSRDocumentStorage: '1.2.840.10008.5.1.4.1.1.88.70',
EncapsulatedPDFStorage: '1.2.840.10008.5.1.4.1.1.104.1',
EncapsulatedCDAStorage: '1.2.840.10008.5.1.4.1.1.104.2',
PositronEmissionTomographyImageStorage: '1.2.840.10008.5.1.4.1.1.128',
EnhancedPETImageStorage: '1.2.840.10008.5.1.4.1.1.130',
LegacyConvertedEnhancedPETImageStorage: '1.2.840.10008.5.1.4.1.1.128.1',
BasicStructuredDisplayStorage: '1.2.840.10008.5.1.4.1.1.131',
RTImageStorage: '1.2.840.10008.5.1.4.1.1.481.1',
RTDoseStorage: '1.2.840.10008.5.1.4.1.1.481.2',
RTStructureSetStorage: '1.2.840.10008.5.1.4.1.1.481.3',
RTBeamsTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.4',
RTPlanStorage: '1.2.840.10008.5.1.4.1.1.481.5',
RTBrachyTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.6',
RTTreatmentSummaryRecordStorage: '1.2.840.10008.5.1.4.1.1.481.7',
RTIonPlanStorage: '1.2.840.10008.5.1.4.1.1.481.8',
RTIonBeamsTreatmentRecordStorage: '1.2.840.10008.5.1.4.1.1.481.9',
RTBeamsDeliveryInstructionStorage: '1.2.840.10008.5.1.4.34.7',
GenericImplantTemplateStorage: '1.2.840.10008.5.1.4.43.1',
ImplantAssemblyTemplateStorage: '1.2.840.10008.5.1.4.44.1',
ImplantTemplateGroupStorage: '1.2.840.10008.5.1.4.45.1',
};
export default sopClassDictionary;

View File

@@ -0,0 +1,40 @@
// Return the array sorting function for its object's properties
export default function sortBy() {
var fields = [].slice.call(arguments),
n_fields = fields.length;
return function (A, B) {
var a, b, field, key, reverse, result, i;
for (i = 0; i < n_fields; i++) {
result = 0;
field = fields[i];
key = typeof field === 'string' ? field : field.name;
a = A[key];
b = B[key];
if (typeof field.primer !== 'undefined') {
a = field.primer(a);
b = field.primer(b);
}
reverse = field.reverse ? -1 : 1;
if (a < b) {
result = reverse * -1;
}
if (a > b) {
result = reverse * 1;
}
if (result !== 0) {
break;
}
}
return result;
};
}

View File

@@ -0,0 +1,61 @@
import sortInstances from './sortInstancesByPosition';
describe('sortInstances', () => {
it('should sort instances based on their imagePositionPatient', () => {
const instances = [
{
ImagePositionPatient: [0, 0, 2],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
{
ImagePositionPatient: [0, 0, 1],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
{
ImagePositionPatient: [0, 0, 0],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
];
const sortedInstances = sortInstances(instances);
expect(sortedInstances).toEqual([
{
ImagePositionPatient: [0, 0, 0],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
{
ImagePositionPatient: [0, 0, 1],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
{
ImagePositionPatient: [0, 0, 2],
ImageOrientationPatient: [1, 0, 0, 0, 1, 0],
},
]);
});
it('should return the same instances if there is only one instance', () => {
const instances = [
{
ImagePositionPatient: [0, 0, 0],
},
];
const sortedInstances = sortInstances(instances);
expect(sortedInstances).toEqual([
{
ImagePositionPatient: [0, 0, 0],
},
]);
});
it('should return the same instances if there are no instances', () => {
const instances = [];
const sortedInstances = sortInstances(instances);
expect(sortedInstances).toEqual([]);
});
});

View File

@@ -0,0 +1,65 @@
import { vec3 } from 'gl-matrix';
/**
* Given an array of imageIds, sort them based on their imagePositionPatient, and
* also returns the spacing between images and the origin of the reference image
*
* @param imageIds - array of imageIds
* @param scanAxisNormal - [x, y, z] array or gl-matrix vec3
*
* @returns The sortedImageIds, zSpacing, and origin of the first image in the series.
*/
export default function sortInstances(instances: Array<any>) {
// Return if only one instance e.g., multiframe
if (instances.length <= 1) {
return instances;
}
const { ImagePositionPatient: referenceImagePositionPatient, ImageOrientationPatient } =
instances[Math.floor(instances.length / 2)]; // this prevents getting scout image as test image
if (!referenceImagePositionPatient || !ImageOrientationPatient) {
return instances;
}
const rowCosineVec = vec3.fromValues(
ImageOrientationPatient[0],
ImageOrientationPatient[1],
ImageOrientationPatient[2]
);
const colCosineVec = vec3.fromValues(
ImageOrientationPatient[3],
ImageOrientationPatient[4],
ImageOrientationPatient[5]
);
const scanAxisNormal = vec3.cross(vec3.create(), rowCosineVec, colCosineVec);
const refIppVec = vec3.set(
vec3.create(),
referenceImagePositionPatient[0],
referenceImagePositionPatient[1],
referenceImagePositionPatient[2]
);
const distanceInstancePairs = instances.map(instance => {
const imagePositionPatient = instance.ImagePositionPatient;
const positionVector = vec3.create();
vec3.sub(positionVector, referenceImagePositionPatient, imagePositionPatient);
const distance = vec3.dot(positionVector, scanAxisNormal);
return {
distance,
instance,
};
});
distanceInstancePairs.sort((a, b) => b.distance - a.distance);
const sortedInstances = distanceInstancePairs.map(a => a.instance);
return sortedInstances;
}

View File

@@ -0,0 +1,119 @@
import isLowPriorityModality from './isLowPriorityModality';
const compareSeriesDateTime = (a, b) => {
const seriesDateA = Date.parse(`${a.seriesDate ?? a.SeriesDate} ${a.seriesTime ?? a.SeriesTime}`);
const seriesDateB = Date.parse(`${a.seriesDate ?? a.SeriesDate} ${a.seriesTime ?? a.SeriesTime}`);
return seriesDateA - seriesDateB;
};
const defaultSeriesSort = (a, b) => {
const seriesNumberA = a.SeriesNumber ?? a.seriesNumber;
const seriesNumberB = b.SeriesNumber ?? b.seriesNumber;
if (seriesNumberA === seriesNumberB) {
return compareSeriesDateTime(a, b);
}
return seriesNumberA - seriesNumberB;
};
/**
* Series sorting criteria: series considered low priority are moved to the end
* of the list and series number is used to break ties
* @param {Object} firstSeries
* @param {Object} secondSeries
*/
function seriesInfoSortingCriteria(firstSeries, secondSeries) {
const aLowPriority = isLowPriorityModality(firstSeries.Modality ?? firstSeries.modality);
const bLowPriority = isLowPriorityModality(secondSeries.Modality ?? secondSeries.modality);
if (aLowPriority) {
// Use the reverse sort order for low priority modalities so that the
// most recent one comes up first as usually that is the one of interest.
return bLowPriority ? defaultSeriesSort(secondSeries, firstSeries) : 1;
} else if (bLowPriority) {
return -1;
}
return defaultSeriesSort(firstSeries, secondSeries);
}
const seriesSortCriteria = {
default: seriesInfoSortingCriteria,
seriesInfoSortingCriteria,
};
const instancesSortCriteria = {
default: (a, b) => parseInt(a.InstanceNumber) - parseInt(b.InstanceNumber),
};
const sortingCriteria = {
seriesSortCriteria,
instancesSortCriteria,
};
/**
* Sorts given series (given param is modified)
* The default criteria is based on series number in ascending order.
*
* @param {Array} series List of series
* @param {function} seriesSortingCriteria method for sorting
* @returns {Array} sorted series object
*/
const sortStudySeries = (
series,
seriesSortingCriteria = seriesSortCriteria.default,
sortFunction = null
) => {
if (typeof sortFunction === 'function') {
return sortFunction(series);
} else {
return series.sort(seriesSortingCriteria);
}
};
/**
* Sorts given instancesList (given param is modified)
* The default criteria is based on instance number in ascending order.
*
* @param {Array} instancesList List of series
* @param {function} instancesSortingCriteria method for sorting
* @returns {Array} sorted instancesList object
*/
const sortStudyInstances = (
instancesList,
instancesSortingCriteria = instancesSortCriteria.default
) => {
return instancesList.sort(instancesSortingCriteria);
};
/**
* Sorts the series and instances (by default) inside a study instance based on sortingCriteria (given param is modified)
* The default criteria is based on series and instance numbers in ascending order.
*
* @param {Object} study The study instance
* @param {boolean} [deepSort = true] to sort instance also
* @param {function} [seriesSortingCriteria = seriesSortCriteria.default] method for sorting series
* @param {function} [instancesSortingCriteria = instancesSortCriteria.default] method for sorting instances
* @returns {Object} sorted study object
*/
export default function sortStudy(
study,
deepSort = true,
seriesSortingCriteria = seriesSortCriteria.default,
instancesSortingCriteria = instancesSortCriteria.default
) {
if (!study || !study.series) {
throw new Error('Insufficient study data was provided to sortStudy');
}
sortStudySeries(study.series, seriesSortingCriteria);
if (deepSort) {
study.series.forEach(series => {
sortStudyInstances(series.instances, instancesSortingCriteria);
});
}
return study;
}
export { sortStudy, sortStudySeries, sortStudyInstances, sortingCriteria, seriesSortCriteria };

View File

@@ -0,0 +1,33 @@
/** Splits a list of strings by commas within the strings */
const splitComma = (strings: string[]): string[] => {
if (!strings) {
return null;
}
for (let i = 0; i < strings.length; i++) {
const comma = strings[i].indexOf(',');
if (comma !== -1) {
const splits = strings[i].split(/,/);
strings.splice(i, 1, ...splits);
}
}
return strings;
};
/**
* Returns an array of the comma split parameters from the given URL search params
* @param lowerCaseKey - lower case search parameter value
* @param params - URLSearchParams
* @returns Array of comma split items matching, or null
*/
const getSplitParam = (
lowerCaseKey: string,
params = new URLSearchParams(window.location.search)
): string[] => {
const sourceKey = [...params.keys()].find(it => it.toLowerCase() === lowerCaseKey);
if (!sourceKey) {
return;
}
return splitComma(params.getAll(sourceKey));
};
export { splitComma, getSplitParam };

View File

@@ -0,0 +1,7 @@
/**
* Convert String to ArrayBuffer
*
* @param {String} str Input String
* @return {ArrayBuffer} Output converted ArrayBuffer
*/
export default str => Uint8Array.from(atob(str), c => c.charCodeAt(0));

View File

@@ -0,0 +1,13 @@
/**
* Returns the values as an array of javascript numbers
*
* @param val - The javascript object for the specified element in the metadata
* @returns {*}
*/
export default function toNumber(val) {
if (Array.isArray(val)) {
return [...val].map(v => (v !== undefined ? Number(v) : v));
} else {
return val !== undefined ? Number(val) : val;
}
}

View File

@@ -0,0 +1,11 @@
/**
* Given the flatten index, and rows and column, it returns the
* row and column index
*/
const unravelIndex = (index, numRows, numCols) => {
const row = Math.floor(index / numCols);
const col = index % numCols;
return { row, col };
};
export default unravelIndex;

View File

@@ -0,0 +1,75 @@
import lib from 'query-string';
const PARAM_SEPARATOR = ';';
const PARAM_PATTERN_IDENTIFIER = ':';
function toLowerCaseFirstLetter(word) {
return word[0].toLowerCase() + word.slice(1);
}
const getQueryFilters = (location = {}) => {
const { search } = location;
if (!search) {
return;
}
const searchParameters = parse(search);
const filters = {};
Object.entries(searchParameters).forEach(([key, value]) => {
filters[toLowerCaseFirstLetter(key)] = value;
});
return filters;
};
const decode = (strToDecode = '') => {
try {
const decoded = window.atob(strToDecode);
return decoded;
} catch (e) {
return strToDecode;
}
};
const parse = toParse => {
if (toParse) {
return lib.parse(toParse);
}
return {};
};
const parseParam = paramStr => {
const _paramDecoded = decode(paramStr);
if (_paramDecoded && typeof _paramDecoded === 'string') {
return _paramDecoded.split(PARAM_SEPARATOR);
}
};
const replaceParam = (path = '', paramKey, paramValue) => {
const paramPattern = `${PARAM_PATTERN_IDENTIFIER}${paramKey}`;
if (paramValue) {
return path.replace(paramPattern, paramValue);
}
return path;
};
const isValidPath = path => {
const paramPatternPiece = `/${PARAM_PATTERN_IDENTIFIER}`;
return path.indexOf(paramPatternPiece) < 0;
};
const queryString = {
getQueryFilters,
};
const paramString = {
isValidPath,
parseParam,
replaceParam,
};
const urlUtil = { parse, queryString, paramString };
export default urlUtil;

View File

@@ -0,0 +1,13 @@
// prettier-ignore
// @ts-nocheck
/**
* Generates a unique id that has limited chance of collision
*
* @see {@link https://stackoverflow.com/a/2117523/1867984|StackOverflow: Source}
* @returns a v4 compliant GUID
*/
export default function uuidv4(): string {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}

View File

@@ -0,0 +1,14 @@
/* jshint -W060 */
import absoluteUrl from './absoluteUrl';
export default function writeScript(fileName, callback) {
const script = document.createElement('script');
script.src = absoluteUrl(fileName);
script.onload = () => {
if (typeof callback === 'function') {
callback(script);
}
};
document.body.appendChild(script);
}