init
This commit is contained in:
64
platform/core/src/utils/Queue.js
Normal file
64
platform/core/src/utils/Queue.js
Normal 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);
|
||||
}
|
||||
69
platform/core/src/utils/Queue.test.js
Normal file
69
platform/core/src/utils/Queue.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
22
platform/core/src/utils/absoluteUrl.js
Normal file
22
platform/core/src/utils/absoluteUrl.js
Normal 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;
|
||||
44
platform/core/src/utils/absoluteUrl.test.js
Normal file
44
platform/core/src/utils/absoluteUrl.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
66
platform/core/src/utils/addAccessors.js
Normal file
66
platform/core/src/utils/addAccessors.js
Normal 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;
|
||||
78
platform/core/src/utils/addServer.test.js
Normal file
78
platform/core/src/utils/addServer.test.js
Normal 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')
|
||||
);
|
||||
});
|
||||
});
|
||||
21
platform/core/src/utils/addServers.js
Normal file
21
platform/core/src/utils/addServers.js
Normal 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;
|
||||
22
platform/core/src/utils/b64toBlob.js
Normal file
22
platform/core/src/utils/b64toBlob.js
Normal 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;
|
||||
62
platform/core/src/utils/combineFrameInstance.ts
Normal file
62
platform/core/src/utils/combineFrameInstance.ts
Normal 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;
|
||||
33
platform/core/src/utils/createStacks.draft-test.js
Normal file
33
platform/core/src/utils/createStacks.draft-test.js
Normal 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);
|
||||
// });
|
||||
// });
|
||||
81
platform/core/src/utils/createStudyBrowserTabs.ts
Normal file
81
platform/core/src/utils/createStudyBrowserTabs.ts
Normal 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;
|
||||
}
|
||||
25
platform/core/src/utils/debounce.js
Normal file
25
platform/core/src/utils/debounce.js
Normal 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;
|
||||
105
platform/core/src/utils/downloadCSVReport.js
Normal file
105
platform/core/src/utils/downloadCSVReport.js
Normal 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();
|
||||
}
|
||||
14
platform/core/src/utils/formatDate.js
Normal file
14
platform/core/src/utils/formatDate.js
Normal 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) : '';
|
||||
};
|
||||
23
platform/core/src/utils/formatPN.js
Normal file
23
platform/core/src/utils/formatPN.js
Normal 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();
|
||||
}
|
||||
12
platform/core/src/utils/formatTime.ts
Normal file
12
platform/core/src/utils/formatTime.ts
Normal 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);
|
||||
}
|
||||
63
platform/core/src/utils/generateAcceptHeader.ts
Normal file
63
platform/core/src/utils/generateAcceptHeader.ts
Normal 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;
|
||||
55
platform/core/src/utils/getImageId.js
Normal file
55
platform/core/src/utils/getImageId.js
Normal 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
|
||||
}
|
||||
}
|
||||
37
platform/core/src/utils/getWADORSImageId.js
Normal file
37
platform/core/src/utils/getWADORSImageId.js
Normal 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}`;
|
||||
}
|
||||
65
platform/core/src/utils/getWADORSImageId.test.js
Normal file
65
platform/core/src/utils/getWADORSImageId.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
28
platform/core/src/utils/guid.js
Normal file
28
platform/core/src/utils/guid.js
Normal 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;
|
||||
46
platform/core/src/utils/guid.test.js
Normal file
46
platform/core/src/utils/guid.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
195
platform/core/src/utils/hierarchicalListUtils.js
Normal file
195
platform/core/src/utils/hierarchicalListUtils.js
Normal 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;
|
||||
96
platform/core/src/utils/hierarchicalListUtils.test.js
Normal file
96
platform/core/src/utils/hierarchicalListUtils.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
8
platform/core/src/utils/hotkeys/index.js
Normal file
8
platform/core/src/utils/hotkeys/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import Mousetrap from 'mousetrap';
|
||||
import pausePlugin from './pausePlugin';
|
||||
import recordPlugin from './recordPlugin';
|
||||
|
||||
recordPlugin(Mousetrap);
|
||||
pausePlugin(Mousetrap);
|
||||
|
||||
export default Mousetrap;
|
||||
32
platform/core/src/utils/hotkeys/pausePlugin.js
Normal file
32
platform/core/src/utils/hotkeys/pausePlugin.js
Normal 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();
|
||||
}
|
||||
216
platform/core/src/utils/hotkeys/recordPlugin.js
Normal file
216
platform/core/src/utils/hotkeys/recordPlugin.js
Normal 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();
|
||||
}
|
||||
12
platform/core/src/utils/imageIdToURI.js
Normal file
12
platform/core/src/utils/imageIdToURI.js
Normal 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);
|
||||
}
|
||||
52
platform/core/src/utils/index.test.js
Normal file
52
platform/core/src/utils/index.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
122
platform/core/src/utils/index.ts
Normal file
122
platform/core/src/utils/index.ts
Normal 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;
|
||||
4
platform/core/src/utils/isDicomUid.js
Normal file
4
platform/core/src/utils/isDicomUid.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function isDicomUid(subject) {
|
||||
const regex = /^\d+(?:\.\d+)*$/;
|
||||
return typeof subject === 'string' && regex.test(subject.trim());
|
||||
}
|
||||
16
platform/core/src/utils/isDicomUid.test.js
Normal file
16
platform/core/src/utils/isDicomUid.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
262
platform/core/src/utils/isDisplaySetReconstructable.js
Normal file
262
platform/core/src/utils/isDisplaySetReconstructable.js
Normal 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,
|
||||
};
|
||||
27
platform/core/src/utils/isEqualWithin.ts
Normal file
27
platform/core/src/utils/isEqualWithin.ts
Normal 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;
|
||||
}
|
||||
65
platform/core/src/utils/isImage.js
Normal file
65
platform/core/src/utils/isImage.js
Normal 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;
|
||||
};
|
||||
279
platform/core/src/utils/isImage.test.js
Normal file
279
platform/core/src/utils/isImage.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
5
platform/core/src/utils/isLowPriorityModality.ts
Normal file
5
platform/core/src/utils/isLowPriorityModality.ts
Normal 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);
|
||||
}
|
||||
23
platform/core/src/utils/makeCancelable.js
Normal file
23
platform/core/src/utils/makeCancelable.js
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
9
platform/core/src/utils/makeDeferred.js
Normal file
9
platform/core/src/utils/makeDeferred.js
Normal 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 });
|
||||
}
|
||||
14
platform/core/src/utils/makeDeferred.test.js
Normal file
14
platform/core/src/utils/makeDeferred.test.js
Normal 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...'));
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
12
platform/core/src/utils/metadataProvider/unpackOverlay.js
Normal file
12
platform/core/src/utils/metadataProvider/unpackOverlay.js
Normal 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;
|
||||
}
|
||||
96
platform/core/src/utils/objectPath.js
Normal file
96
platform/core/src/utils/objectPath.js
Normal 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;
|
||||
99
platform/core/src/utils/objectPath.test.js
Normal file
99
platform/core/src/utils/objectPath.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
326
platform/core/src/utils/progressTrackingUtils.js
Normal file
326
platform/core/src/utils/progressTrackingUtils.js
Normal 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;
|
||||
171
platform/core/src/utils/progressTrackingUtils.test.js
Normal file
171
platform/core/src/utils/progressTrackingUtils.test.js
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
platform/core/src/utils/reconstructableModalities.js
Normal file
3
platform/core/src/utils/reconstructableModalities.js
Normal file
@@ -0,0 +1,3 @@
|
||||
const reconstructableModalities = ['MR', 'CT', 'PT', 'NM'];
|
||||
|
||||
export default reconstructableModalities;
|
||||
15
platform/core/src/utils/resolveObjectPath.js
Normal file
15
platform/core/src/utils/resolveObjectPath.js
Normal 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;
|
||||
}
|
||||
}
|
||||
35
platform/core/src/utils/resolveObjectPath.test.js
Normal file
35
platform/core/src/utils/resolveObjectPath.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
43
platform/core/src/utils/roundNumber.js
Normal file
43
platform/core/src/utils/roundNumber.js
Normal 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;
|
||||
119
platform/core/src/utils/sopClassDictionary.js
Normal file
119
platform/core/src/utils/sopClassDictionary.js
Normal 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;
|
||||
40
platform/core/src/utils/sortBy.js
Normal file
40
platform/core/src/utils/sortBy.js
Normal 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;
|
||||
};
|
||||
}
|
||||
61
platform/core/src/utils/sortInstancesByPosition.test.js
Normal file
61
platform/core/src/utils/sortInstancesByPosition.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
65
platform/core/src/utils/sortInstancesByPosition.ts
Normal file
65
platform/core/src/utils/sortInstancesByPosition.ts
Normal 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;
|
||||
}
|
||||
119
platform/core/src/utils/sortStudy.ts
Normal file
119
platform/core/src/utils/sortStudy.ts
Normal 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 };
|
||||
33
platform/core/src/utils/splitComma.ts
Normal file
33
platform/core/src/utils/splitComma.ts
Normal 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 };
|
||||
7
platform/core/src/utils/str2ab.js
Normal file
7
platform/core/src/utils/str2ab.js
Normal 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));
|
||||
13
platform/core/src/utils/toNumber.js
Normal file
13
platform/core/src/utils/toNumber.js
Normal 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;
|
||||
}
|
||||
}
|
||||
11
platform/core/src/utils/unravelIndex.ts
Normal file
11
platform/core/src/utils/unravelIndex.ts
Normal 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;
|
||||
75
platform/core/src/utils/urlUtil.js
Normal file
75
platform/core/src/utils/urlUtil.js
Normal 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;
|
||||
13
platform/core/src/utils/uuidv4.ts
Normal file
13
platform/core/src/utils/uuidv4.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
14
platform/core/src/utils/writeScript.js
Normal file
14
platform/core/src/utils/writeScript.js
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user