feat: cornerstone3D stack and volume viewports (#2787)
* feat: cs3d working stack viewport and tools (#19) * Squashed everything * fix weird react issue * fix eslint / prettier stuff * thumbnails work now with cpu rendering * feat: use new loadImageToCanvas in cs3d * make jump to measurement work * fix active thumbnail * fix measurement delete * fix window level presets * remove segmentation and sync groups for now * fix the dicom pdf and dicom video * fix cornerstone window assignment for cypress * apply review comments Co-authored-by: Erik <erik.sweed@gmail.com> * feat: cs3d tools and toolGroups (#20) * add more tools to work with cs3d mode * fix hotkeys * add stack manager usage for stach viewports * add image scrollbar * wip viewport overlay * fix toAnnotation schema for tools * fix the unnecessary size change that triggered resize * hanging protocol improvement to allow unmatched errors * study description matching for hanging protocol * fix the displaysetOptions to work * fix handle the active tool when a new viewport is added * fix separate toolGroups for mode * apply review comments * apply review comments * yarn lock * feat: overlay component (#21) * fix default displayset options * add viewport overlay * apply review comments * feat: loading and orientation indicators (#22) * add loading indicator * add orientation marker initial work * apply review comments * fix: orientation markers (#23) * finished the orientation markers * fix various broken cypress tests * apply review comments * update yarn lock * feat: re-working measurement tracking mode with cornerstone3d (#2805) * feat: cs3d working stack viewport and tools (#19) * Squashed everything * fix weird react issue * fix eslint / prettier stuff * thumbnails work now with cpu rendering * feat: use new loadImageToCanvas in cs3d * make jump to measurement work * fix active thumbnail * fix measurement delete * fix window level presets * remove segmentation and sync groups for now * fix the dicom pdf and dicom video * fix cornerstone window assignment for cypress * apply review comments Co-authored-by: Erik <erik.sweed@gmail.com> * feat: Add Measurement tracking mode with cs3D (#2789) * feat: first render for cornerstone3d tracked viewport * make tool active work * wip for SR extension * renamed dicom sr to cornerstone dicom sr * remove cornerstone from dicom pdf and video * move dicom sr logic to sr extension * feat: Add hydration for the length tool * fix SR display tool for length using cs3d * fix default config * fix: various bugs with sr viewport and tracking * fix promptying to continue tracking for when SR is created * feat: add keep trackign of unique identifiers * fix hydration for same imageIds * feat: Add SR toolGroup creation on modeEnter * feat: remove the need for separate mapper for SR hydration * add SR display for ellipse * handle hydration of elliptical ROI tool * remove cornerstone extension * add arrow mapping * feat: Add ArrowAnnotate SR display and hydration * apply review comments * apply review comments * move viewport labels to the viewportData * apply review comments * fix: integration cypress tests with cornerstone3D and add CINE tool (#2795) * fix: integration cypress tests with cornerstone3D * revert to addOrUpdate as it makes more sense * fix local drag and drop * fix tests * move dicomLoaderService to cornerstone extension * fix various import bugs * fix bug for local PT series * fix various unit tests * bump cs3d versions * add angle and magnify tool * bump dependencies to avoid broken peerDeps * feat: add initial work for capture using cs3d * feat: show annotations on the image capture * feat: add svg layer export * feat: Add CINE Tool * feat: remove unnecessary viewport rendering for cine state changes Co-authored-by: Erik Ziegler <erik.sweed@gmail.com> * docs: modify and improve documentation (#2800) * cleanup docs versionings * feat: Add all version explanations * version docs for 3.0 * wip for changing docs * wip for updated docs * add utility module documentation * fix demo with nohoisting of history * add slides and video to resources * apply review comments * fix: drag and drop SR into SR viewport (#2803) * update yarn lock Co-authored-by: Erik <erik.sweed@gmail.com> * update pathnames to match v3-stable * update the e2e pathname * fix: various bugs with regard to tracking workflow (#2811) * fix: various issues with measurement panel * fix: update default tool style for annotations * fix: annotatoin label getting removed * feat: Add backward compatibility for SR hydration with legacy cornerstone * fix: cursors and ellipse ROI max style * fix: ArrowAnnotate SRDisplay * apply review comments * bump package versions * fix: bugin rehydration of SR * fix: e2e tests * fix active viewport thickness and arrowTool ui * add readme for measurement tracking * use uploaded image for readme * add back images * try to fix e2e test * fix: window level presets hotkeys * Update README.md * update yarn lock * feat: volume api and TMTV mode (#2817) * feat: volumeAPI and TMTV mode * feat: use cs3d cache service to obtain viewportData * wip for volume api * wip: fusion viewport * feat: add blend mode option * wip for image scrollbar * fix drag and drop thumbnail * wip for image scrollbar for voluems * fix: element mismatch bug for scroll * feat: Add image scrollbar to volumes * feat: add syncGroups to volume api * feat: add tmtv mode initial setup * feat: add initial image options for the stack viewports * feat: Add custom load strategy for volume viewports via HP * apply review comments fix: Jump presets cs3d (#2812) * feat: Add JumpPreset to OHIF for Cornerstone3D * fix: accessing viewport service from servicesManager feat: volume API and TMTV mode (#2814) * fix: do not display overlays on mip viewports * feat: add optional disableCommands for toggle buttons * fix: toggleCrossharis * feat: config the crosshairs * feat: add PetSUV Panel for changing metadata * feat: initial work for the rectangleROIThreshold panel * wip: measurement service * roi threshold working * feat: Add displayText to segmentations * feat: add remove segmentation * feat: add csv export * feat: add RT export for annotations in tmtv mode * fix: fusion to use pt in tmtv mode and measuremet mappings * fix: various bugs * apply review comments * apply review comments * feat: add fusion color maps * add readme to tmtv mode * Update README.md * fix: try to fix build * fix unit tests * Update README.md * feat: add about to the panel * fix: changing strategy in roi panel * apply review comments * update package versions * wip for stackPrefetch * fix: cornerstone3d hydration and renaming (#2818) * update readme * renamed cornerstone extension * wip for renaming cornerstone3D variables * wip for renaming cornerstone3D variables * wip for fixing bugs for SR viewport * fix: jumpToMeasurement and initial label after hydration * fix: fileName capitalization * feat: use the new prefetch stack in the cs3d (#2820) * feat: use the new prefetch stack in the cs3d * use viewport scroll api for stack viewport * fix cine stop when scrollbar changes * feat: use new prefetch events * fix loading state to not show repeatedly * fix: various bugs for tmtv mode thresholding and new icons (#2823) * feat: make tmtv mode available in worklist * feat: add new icons for tmtv mode * feat: add fusion color icon * fix: parallel scale calculation * fix: bump Cornerstone to get large image support working * fix: Fix issues with CPU viewport flipping, including config files * fix: bump cornerstone to fix magnify tool * fix: Bump Cornerstone version to fix resetCamera issue in StackViewport * ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys * fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors * feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838) Also works with the compile time add that the existing cli uses, so that both build types work. Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes. * bump: dependency versions to fix hydration bugs (#2848) * bump: dcmjs version to fix hydration bugs * try to fix tests * bump dependency versions Co-authored-by: Alireza <ar.sedghi@gmail.com> Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
668 files changed
+41669
-13410
No files matched your search
@@ -1,240 +0,0 @@
|
||||
import log from '../log';
|
||||
import utils from '../utils';
|
||||
// import {
|
||||
// retrieveMeasurementFromSR,
|
||||
// stowSRFromMeasurements,
|
||||
// } from './handleStructuredReport';
|
||||
import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport';
|
||||
import cornerstone from 'cornerstone-core';
|
||||
import cornerstoneTools from 'cornerstone-tools';
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
const { MeasurementReport } = dcmjs.adapters.Cornerstone;
|
||||
|
||||
/**
|
||||
*
|
||||
* @typedef serverType
|
||||
* @property {string} type - type of the server
|
||||
* @property {string} wadoRoot - server wado root url
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Function to be registered into MeasurementAPI to retrieve measurements from DICOM Structured Reports
|
||||
*
|
||||
* @param {serverType} server
|
||||
* @returns {Promise} Should resolve with OHIF measurementData object
|
||||
*/
|
||||
/*const retrieveMeasurements = server => {
|
||||
log.info('[DICOMSR] retrieveMeasurements');
|
||||
|
||||
if (!server || server.type !== 'dicomWeb') {
|
||||
log.error('[DICOMSR] DicomWeb server is required!');
|
||||
return Promise.reject({});
|
||||
}
|
||||
|
||||
const serverUrl = server.wadoRoot;
|
||||
const studies = utils.studyMetadataManager.all();
|
||||
|
||||
const latestSeries = findMostRecentStructuredReport(studies);
|
||||
|
||||
if (!latestSeries) return Promise.resolve({});
|
||||
|
||||
return retrieveMeasurementFromSR(latestSeries, studies, serverUrl);
|
||||
};*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object[]} measurementData An array of measurements from the measurements service
|
||||
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* as opposed to Finding Sites.
|
||||
* that you wish to serialize.
|
||||
*/
|
||||
const downloadReport = (
|
||||
measurementData,
|
||||
additionalFindingTypes,
|
||||
options = {}
|
||||
) => {
|
||||
const srDataset = generateReport(
|
||||
measurementData,
|
||||
additionalFindingTypes,
|
||||
options
|
||||
);
|
||||
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
|
||||
|
||||
//Create a URL for the binary.
|
||||
var objectUrl = URL.createObjectURL(reportBlob);
|
||||
window.location.assign(objectUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object[]} measurementData An array of measurements from the measurements service
|
||||
* that you wish to serialize.
|
||||
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
*
|
||||
*/
|
||||
const generateReport = (
|
||||
measurementData,
|
||||
additionalFindingTypes,
|
||||
options = {}
|
||||
) => {
|
||||
const filteredToolState = _getFilteredCornerstoneToolState(
|
||||
measurementData,
|
||||
additionalFindingTypes
|
||||
);
|
||||
const report = MeasurementReport.generateReport(
|
||||
filteredToolState,
|
||||
cornerstone.metaData
|
||||
);
|
||||
|
||||
const { dataset } = report;
|
||||
|
||||
// Add in top level series options
|
||||
Object.assign(dataset, options);
|
||||
|
||||
return dataset;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object[]} measurementData An array of measurements from the measurements service
|
||||
* that you wish to serialize.
|
||||
* @param {object} dataSource The dataSource that you wish to use to persist the data.
|
||||
* @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings
|
||||
* @param {object} options Naturalized DICOM JSON headers to merge into the displaySet.
|
||||
* @return {object} The naturalized report
|
||||
*/
|
||||
const storeMeasurements = async (
|
||||
measurementData,
|
||||
dataSource,
|
||||
additionalFindingTypes,
|
||||
options = {}
|
||||
) => {
|
||||
// TODO -> Eventually use the measurements directly and not the dcmjs adapter,
|
||||
// But it is good enough for now whilst we only have cornerstone as a datasource.
|
||||
log.info('[DICOMSR] storeMeasurements');
|
||||
|
||||
if (!dataSource || !dataSource.store || !dataSource.store.dicom) {
|
||||
log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!');
|
||||
return Promise.reject({});
|
||||
}
|
||||
|
||||
try {
|
||||
const naturalizedReport = generateReport(
|
||||
measurementData,
|
||||
additionalFindingTypes,
|
||||
options
|
||||
);
|
||||
const { StudyInstanceUID } = naturalizedReport;
|
||||
|
||||
await dataSource.store.dicom(naturalizedReport);
|
||||
|
||||
if (StudyInstanceUID) {
|
||||
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
||||
}
|
||||
|
||||
return naturalizedReport;
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
log.error(
|
||||
`[DICOMSR] Error while saving the measurements: ${error.message}`
|
||||
);
|
||||
throw new Error(error.message || 'Error while saving the measurements.');
|
||||
}
|
||||
};
|
||||
|
||||
// _getFilteredCornerstoneToolState
|
||||
// DIFFERENT IMPLEMENTATION HERE! What's up?
|
||||
function _getFilteredCornerstoneToolState(
|
||||
measurementData,
|
||||
additionalFindingTypes
|
||||
) {
|
||||
const uidFilter = measurementData.map(md => md.id);
|
||||
|
||||
const globalToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState();
|
||||
const filteredToolState = {};
|
||||
|
||||
function addToFilteredToolState(imageId, toolType, toolDataI) {
|
||||
if (!filteredToolState[imageId]) {
|
||||
filteredToolState[imageId] = {};
|
||||
}
|
||||
|
||||
const imageIdSpecificToolState = filteredToolState[imageId];
|
||||
|
||||
if (!imageIdSpecificToolState[toolType]) {
|
||||
imageIdSpecificToolState[toolType] = {
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
const measurmentDataI = measurementData.find(md => md.id === toolDataI.id);
|
||||
const toolData = imageIdSpecificToolState[toolType].data;
|
||||
|
||||
let finding;
|
||||
const findingSites = [];
|
||||
|
||||
// NOTE -> Any kind of freetext value abuses the DICOM standard,
|
||||
// As CodeValues should map 1:1 with CodeMeanings.
|
||||
// Ideally we would actually use SNOMED codes for this.
|
||||
if (measurmentDataI.label) {
|
||||
if (additionalFindingTypes.includes(toolType)) {
|
||||
finding = {
|
||||
CodeValue: 'CORNERSTONEFREETEXT',
|
||||
CodingSchemeDesignator: 'CST4',
|
||||
CodeMeaning: measurmentDataI.label,
|
||||
};
|
||||
} else {
|
||||
findingSites.push({
|
||||
CodeValue: 'CORNERSTONEFREETEXT',
|
||||
CodingSchemeDesignator: 'CST4',
|
||||
CodeMeaning: measurmentDataI.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const measurement = Object.assign({}, toolDataI, {
|
||||
finding,
|
||||
findingSites,
|
||||
});
|
||||
|
||||
toolData.push(measurement);
|
||||
}
|
||||
|
||||
const uids = uidFilter.slice();
|
||||
const imageIds = Object.keys(globalToolState);
|
||||
|
||||
for (let i = 0; i < imageIds.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
const imageIdSpecificToolState = globalToolState[imageId];
|
||||
|
||||
const toolTypes = Object.keys(imageIdSpecificToolState);
|
||||
|
||||
for (let j = 0; j < toolTypes.length; j++) {
|
||||
const toolType = toolTypes[j];
|
||||
const toolData = imageIdSpecificToolState[toolType].data;
|
||||
|
||||
if (toolData) {
|
||||
for (let k = 0; k < toolData.length; k++) {
|
||||
const toolDataK = toolData[k];
|
||||
const uidIndex = uids.findIndex(uid => uid === toolDataK.id);
|
||||
|
||||
if (uidIndex !== -1) {
|
||||
addToFilteredToolState(imageId, toolType, toolDataK);
|
||||
uids.splice(uidIndex, 1);
|
||||
|
||||
if (!uids.length) {
|
||||
return filteredToolState;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredToolState;
|
||||
}
|
||||
|
||||
export { storeMeasurements, downloadReport };
|
||||
@@ -1,15 +0,0 @@
|
||||
import {
|
||||
//retrieveMeasurements,
|
||||
storeMeasurements,
|
||||
downloadReport,
|
||||
} from './dataExchange';
|
||||
import isToolSupported from './utils/isToolSupported';
|
||||
|
||||
const DICOMSR = {
|
||||
//retrieveMeasurements,
|
||||
storeMeasurements,
|
||||
downloadReport,
|
||||
isToolSupported,
|
||||
};
|
||||
|
||||
export default DICOMSR;
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Should Find the requested instance metadata into the displaySets and return
|
||||
*
|
||||
* @param {Array} displaySets - List of displaySets
|
||||
* @param {string} SOPInstanceUID - sopInstanceUID to look for
|
||||
* @returns {Object} - instance metadata found
|
||||
*/
|
||||
const findInstanceMetadataBySopInstanceUID = (displaySets, SOPInstanceUID) => {
|
||||
let instanceFound;
|
||||
|
||||
displaySets.find(displaySet => {
|
||||
if (!displaySet.images) return false;
|
||||
|
||||
instanceFound = displaySet.images.find(
|
||||
instanceMetadata =>
|
||||
instanceMetadata.getSOPInstanceUID() === SOPInstanceUID
|
||||
);
|
||||
|
||||
return !!instanceFound;
|
||||
});
|
||||
|
||||
return instanceFound;
|
||||
};
|
||||
|
||||
export default findInstanceMetadataBySopInstanceUID;
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Should find the most recent Structured Report metadata
|
||||
*
|
||||
* @param {Array} studies
|
||||
* @returns {Object} Series
|
||||
*/
|
||||
const findMostRecentStructuredReport = studies => {
|
||||
let mostRecentStructuredReport;
|
||||
|
||||
studies.forEach(study => {
|
||||
const allSeries = study.getSeries ? study.getSeries() : [];
|
||||
allSeries.forEach(series => {
|
||||
// Skip series that may not have instances yet
|
||||
// This can happen if we have retrieved just the initial
|
||||
// details about the series via QIDO-RS, but not the full metadata
|
||||
if (!series.instances.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStructuredReportSeries(series)) {
|
||||
if (
|
||||
!mostRecentStructuredReport ||
|
||||
compareSeriesDate(series, mostRecentStructuredReport)
|
||||
) {
|
||||
mostRecentStructuredReport = series;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return mostRecentStructuredReport;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if series sopClassUID matches with the supported Structured Reports sopClassUID
|
||||
*
|
||||
* @param {Object} series - Series metadata
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isStructuredReportSeries = series => {
|
||||
const supportedSopClassUIDs = [
|
||||
'1.2.840.10008.5.1.4.1.1.88.22',
|
||||
'1.2.840.10008.5.1.4.1.1.11.1',
|
||||
];
|
||||
|
||||
const firstInstance = series.getFirstInstance();
|
||||
const SOPClassUID = firstInstance.getData().metadata.SOPClassUID;
|
||||
|
||||
return supportedSopClassUIDs.includes(SOPClassUID);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checkes if series1 is newer than series2
|
||||
*
|
||||
* @param {Object} series1 - Series Metadata 1
|
||||
* @param {Object} series2 - Series Metadata 2
|
||||
* @returns {boolean} true/false if series1 is newer than series2
|
||||
*/
|
||||
const compareSeriesDate = (series1, series2) => {
|
||||
return (
|
||||
series1._data.SeriesDate > series2._data.SeriesDate ||
|
||||
(series1._data.SeriesDate === series2._data.SeriesDate &&
|
||||
series1._data.SeriesTime > series2._data.SeriesTime)
|
||||
);
|
||||
};
|
||||
|
||||
export default findMostRecentStructuredReport;
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Retrieve a list of all displaySets of all studies
|
||||
*
|
||||
* @param {Object} studies - List of studies loaded into the viewer
|
||||
* @returns {Object} List of DisplaySets
|
||||
*/
|
||||
const getAllDisplaySets = studies => {
|
||||
let allDisplaySets = [];
|
||||
|
||||
studies.forEach(study => {
|
||||
if (study.getDisplaySets) {
|
||||
allDisplaySets = allDisplaySets.concat(study.getDisplaySets());
|
||||
}
|
||||
});
|
||||
|
||||
return allDisplaySets;
|
||||
};
|
||||
|
||||
export default getAllDisplaySets;
|
||||
@@ -1,14 +0,0 @@
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
/**
|
||||
* Checks if dcmjs has support to determined tool
|
||||
*
|
||||
* @param {string} toolName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isToolSupported = toolName => {
|
||||
const adapter = dcmjs.adapters.Cornerstone;
|
||||
return !!adapter[toolName];
|
||||
};
|
||||
|
||||
export default isToolSupported;
|
||||
@@ -18,16 +18,8 @@ import log from '../log.js';
|
||||
* to extend this class, please check it's source before adding new methods.
|
||||
*/
|
||||
export class CommandsManager {
|
||||
constructor({ getActiveContexts } = {}) {
|
||||
constructor({} = {}) {
|
||||
this.contexts = {};
|
||||
|
||||
if (!getActiveContexts) {
|
||||
throw new Error(
|
||||
'CommandsManager was instantiated without getActiveContexts()'
|
||||
);
|
||||
}
|
||||
|
||||
this._getActiveContexts = getActiveContexts;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,12 +114,8 @@ export class CommandsManager {
|
||||
contexts.push(context);
|
||||
}
|
||||
} else {
|
||||
const activeContexts = this._getActiveContexts();
|
||||
activeContexts.forEach(activeContext => {
|
||||
const context = this.getContext(activeContext);
|
||||
if (context) {
|
||||
contexts.push(context);
|
||||
}
|
||||
Object.keys(this.contexts).forEach(contextName => {
|
||||
contexts.push(this.getContext(contextName));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ describe('CommandsManager', () => {
|
||||
options: { passMeToCommandFn: ':wave:' },
|
||||
},
|
||||
commandsManagerConfig = {
|
||||
getActiveContexts: () => ['VIEWER', 'ACTIVE_VIEWER::CORNERSTONE'],
|
||||
getAppState: () => {
|
||||
return {
|
||||
viewers: 'Test',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,12 +33,6 @@ describe('CommandsManager', () => {
|
||||
expect(localCommandsManager.contexts).toEqual({});
|
||||
});
|
||||
|
||||
it('throws Error if instantiated without getActiveContexts', () => {
|
||||
expect(() => {
|
||||
new CommandsManager();
|
||||
}).toThrow(new Error('CommandsManager was instantiated without getActiveContexts()'));
|
||||
});
|
||||
|
||||
describe('createContext()', () => {
|
||||
it('creates a context', () => {
|
||||
commandsManager.createContext(contextName);
|
||||
|
||||
@@ -21,13 +21,13 @@ describe('HotkeysManager', () => {
|
||||
});
|
||||
it('has expected properties', () => {
|
||||
const allProperties = Object.keys(hotkeysManager);
|
||||
const expectedProprties = [
|
||||
const expectedProperties = [
|
||||
'hotkeyDefinitions',
|
||||
'hotkeyDefaults',
|
||||
'isEnabled',
|
||||
];
|
||||
|
||||
const containsAllExpectedProperties = expectedProprties.every(expected =>
|
||||
const containsAllExpectedProperties = expectedProperties.every(expected =>
|
||||
allProperties.includes(expected)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import queryString from 'query-string';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import { imageIdToURI } from '../utils';
|
||||
import getPixelSpacingInformation from '../utils/metadataProvider/getPixelSpacingInformation';
|
||||
import DicomMetadataStore from '../services/DicomMetadataStore';
|
||||
import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData';
|
||||
import toNumber from '../utils/toNumber';
|
||||
|
||||
class MetadataProvider {
|
||||
constructor() {
|
||||
@@ -13,7 +15,17 @@ class MetadataProvider {
|
||||
writable: false,
|
||||
value: new Map(),
|
||||
});
|
||||
Object.defineProperty(this, 'imageIdToUIDs', {
|
||||
Object.defineProperty(this, 'imageURIToUIDs', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: new Map(),
|
||||
});
|
||||
// Can be used to store custom metadata for a specific type.
|
||||
// For instance, the scaling metadata for PET can be stored here
|
||||
// as type "scalingModule"
|
||||
//
|
||||
Object.defineProperty(this, 'customMetadata', {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
@@ -21,13 +33,21 @@ class MetadataProvider {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
addImageIdToUIDs(imageId, uids) {
|
||||
// This method is a fallback for when you don't have WADO-URI or WADO-RS.
|
||||
// You can add instances fetched by any method by calling addInstance, and hook an imageId to point at it here.
|
||||
// An example would be dicom hosted at some random site.
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
this.imageURIToUIDs.set(imageURI, uids);
|
||||
}
|
||||
|
||||
this.imageIdToUIDs.set(imageId, uids);
|
||||
addCustomMetadata(imageId, type, metadata) {
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
if (!this.customMetadata.has(type)) {
|
||||
this.customMetadata.set(type, {});
|
||||
}
|
||||
|
||||
this.customMetadata.get(type)[imageURI] = metadata;
|
||||
}
|
||||
|
||||
_getInstance(imageId) {
|
||||
@@ -53,6 +73,15 @@ class MetadataProvider {
|
||||
return instance;
|
||||
}
|
||||
|
||||
// check inside custom metadata
|
||||
if (this.customMetadata.has(query)) {
|
||||
const customMetadata = this.customMetadata.get(query);
|
||||
const imageURI = imageIdToURI(imageId);
|
||||
if (customMetadata[imageURI]) {
|
||||
return customMetadata[imageURI];
|
||||
}
|
||||
}
|
||||
|
||||
return this.getTagFromInstance(query, instance, options);
|
||||
}
|
||||
|
||||
@@ -106,7 +135,7 @@ class MetadataProvider {
|
||||
metadata = {
|
||||
modality: instance.Modality,
|
||||
seriesInstanceUID: instance.SeriesInstanceUID,
|
||||
seriesNumber: validNumber(instance.SeriesNumber),
|
||||
seriesNumber: toNumber(instance.SeriesNumber),
|
||||
studyInstanceUID: instance.StudyInstanceUID,
|
||||
seriesDate,
|
||||
seriesTime,
|
||||
@@ -114,9 +143,14 @@ class MetadataProvider {
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
|
||||
metadata = {
|
||||
patientAge: validNumber(instance.PatientAge),
|
||||
patientSize: validNumber(instance.PatientSize),
|
||||
patientWeight: validNumber(instance.PatientWeight),
|
||||
patientAge: toNumber(instance.PatientAge),
|
||||
patientSize: toNumber(instance.PatientSize),
|
||||
patientWeight: toNumber(instance.PatientWeight),
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.PATIENT_DEMOGRAPHIC_MODULE:
|
||||
metadata = {
|
||||
patientSex: instance.PatientSex,
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.IMAGE_PLANE_MODULE:
|
||||
@@ -145,50 +179,56 @@ class MetadataProvider {
|
||||
|
||||
metadata = {
|
||||
frameOfReferenceUID: instance.FrameOfReferenceUID,
|
||||
rows: validNumber(instance.Rows),
|
||||
columns: validNumber(instance.Columns),
|
||||
imageOrientationPatient: validNumber(ImageOrientationPatient),
|
||||
rowCosines: validNumber(rowCosines),
|
||||
columnCosines: validNumber(columnCosines),
|
||||
imagePositionPatient: validNumber(instance.ImagePositionPatient),
|
||||
sliceThickness: validNumber(instance.SliceThickness),
|
||||
sliceLocation: validNumber(instance.SliceLocation),
|
||||
pixelSpacing: validNumber(PixelSpacing),
|
||||
rowPixelSpacing: validNumber(rowPixelSpacing),
|
||||
columnPixelSpacing: validNumber(columnPixelSpacing),
|
||||
rows: toNumber(instance.Rows),
|
||||
columns: toNumber(instance.Columns),
|
||||
imageOrientationPatient: toNumber(ImageOrientationPatient),
|
||||
rowCosines: toNumber(rowCosines),
|
||||
columnCosines: toNumber(columnCosines),
|
||||
imagePositionPatient: toNumber(instance.ImagePositionPatient),
|
||||
sliceThickness: toNumber(instance.SliceThickness),
|
||||
sliceLocation: toNumber(instance.SliceLocation),
|
||||
pixelSpacing: toNumber(PixelSpacing),
|
||||
rowPixelSpacing: toNumber(rowPixelSpacing),
|
||||
columnPixelSpacing: toNumber(columnPixelSpacing),
|
||||
};
|
||||
break;
|
||||
case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE:
|
||||
metadata = {
|
||||
samplesPerPixel: validNumber(instance.SamplesPerPixel),
|
||||
samplesPerPixel: toNumber(instance.SamplesPerPixel),
|
||||
photometricInterpretation: instance.PhotometricInterpretation,
|
||||
rows: validNumber(instance.Rows),
|
||||
columns: validNumber(instance.Columns),
|
||||
bitsAllocated: validNumber(instance.BitsAllocated),
|
||||
bitsStored: validNumber(instance.BitsStored),
|
||||
highBit: validNumber(instance.HighBit),
|
||||
pixelRepresentation: validNumber(instance.PixelRepresentation),
|
||||
planarConfiguration: validNumber(instance.PlanarConfiguration),
|
||||
pixelAspectRatio: validNumber(instance.PixelAspectRatio),
|
||||
smallestPixelValue: validNumber(instance.SmallestPixelValue),
|
||||
largestPixelValue: validNumber(instance.LargestPixelValue),
|
||||
redPaletteColorLookupTableDescriptor: validNumber(
|
||||
rows: toNumber(instance.Rows),
|
||||
columns: toNumber(instance.Columns),
|
||||
bitsAllocated: toNumber(instance.BitsAllocated),
|
||||
bitsStored: toNumber(instance.BitsStored),
|
||||
highBit: toNumber(instance.HighBit),
|
||||
pixelRepresentation: toNumber(instance.PixelRepresentation),
|
||||
planarConfiguration: toNumber(instance.PlanarConfiguration),
|
||||
pixelAspectRatio: toNumber(instance.PixelAspectRatio),
|
||||
smallestPixelValue: toNumber(instance.SmallestPixelValue),
|
||||
largestPixelValue: toNumber(instance.LargestPixelValue),
|
||||
redPaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.RedPaletteColorLookupTableDescriptor
|
||||
),
|
||||
greenPaletteColorLookupTableDescriptor: validNumber(
|
||||
greenPaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.GreenPaletteColorLookupTableDescriptor
|
||||
),
|
||||
bluePaletteColorLookupTableDescriptor: validNumber(
|
||||
bluePaletteColorLookupTableDescriptor: toNumber(
|
||||
instance.BluePaletteColorLookupTableDescriptor
|
||||
),
|
||||
redPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance, "RedPaletteColorLookupTableData", "RedPaletteColorLookupTableDescriptor"
|
||||
instance,
|
||||
'RedPaletteColorLookupTableData',
|
||||
'RedPaletteColorLookupTableDescriptor'
|
||||
),
|
||||
greenPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance, "GreenPaletteColorLookupTableData", "GreenPaletteColorLookupTableDescriptor"
|
||||
instance,
|
||||
'GreenPaletteColorLookupTableData',
|
||||
'GreenPaletteColorLookupTableDescriptor'
|
||||
),
|
||||
bluePaletteColorLookupTableData: fetchPaletteColorLookupTableData(
|
||||
instance, "BluePaletteColorLookupTableData", "BluePaletteColorLookupTableDescriptor"
|
||||
instance,
|
||||
'BluePaletteColorLookupTableData',
|
||||
'BluePaletteColorLookupTableDescriptor'
|
||||
),
|
||||
};
|
||||
|
||||
@@ -206,8 +246,8 @@ class MetadataProvider {
|
||||
: [WindowWidth];
|
||||
|
||||
metadata = {
|
||||
windowCenter: validNumber(windowCenter),
|
||||
windowWidth: validNumber(windowWidth),
|
||||
windowCenter: toNumber(windowCenter),
|
||||
windowWidth: toNumber(windowWidth),
|
||||
};
|
||||
|
||||
break;
|
||||
@@ -218,8 +258,8 @@ class MetadataProvider {
|
||||
}
|
||||
|
||||
metadata = {
|
||||
rescaleIntercept: validNumber(instance.RescaleIntercept),
|
||||
rescaleSlope: validNumber(instance.RescaleSlope),
|
||||
rescaleIntercept: toNumber(instance.RescaleIntercept),
|
||||
rescaleSlope: toNumber(instance.RescaleSlope),
|
||||
rescaleType: instance.RescaleType,
|
||||
};
|
||||
break;
|
||||
@@ -331,7 +371,7 @@ class MetadataProvider {
|
||||
case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE:
|
||||
metadata = {
|
||||
sopInstanceUid: instance.SOPInstanceUID,
|
||||
instanceNumber: validNumber(instance.InstanceNumber),
|
||||
instanceNumber: toNumber(instance.InstanceNumber),
|
||||
lossyImageCompression: instance.LossyImageCompression,
|
||||
lossyImageCompressionRatio: instance.LossyImageCompressionRatio,
|
||||
lossyImageCompressionMethod: instance.LossyImageCompressionMethod,
|
||||
@@ -361,7 +401,14 @@ class MetadataProvider {
|
||||
}
|
||||
|
||||
_getUIDsFromImageID(imageId) {
|
||||
if (imageId.includes('wadors:')) {
|
||||
// TODO: adding csiv here is not really correct. Probably need to use
|
||||
// metadataProvider.addImageIdToUIDs(imageId, {
|
||||
// StudyInstanceUID,
|
||||
// SeriesInstanceUID,
|
||||
// SOPInstanceUID,
|
||||
// })
|
||||
// somewhere else
|
||||
if (imageId.startsWith('wadors:')) {
|
||||
const strippedImageId = imageId.split('/studies/')[1];
|
||||
const splitImageId = strippedImageId.split('/');
|
||||
|
||||
@@ -381,26 +428,22 @@ class MetadataProvider {
|
||||
}
|
||||
|
||||
// Maybe its a non-standard imageId
|
||||
return this.imageIdToUIDs.get(imageId);
|
||||
// check if the imageId starts with http:// or https:// using regex
|
||||
// Todo: handle non http imageIds
|
||||
let imageURI;
|
||||
const urlRegex = /^(http|https):\/\//;
|
||||
if (urlRegex.test(imageId)) {
|
||||
imageURI = imageId;
|
||||
} else {
|
||||
imageURI = imageIdToURI(imageId);
|
||||
}
|
||||
|
||||
return this.imageURIToUIDs.get(imageURI);
|
||||
}
|
||||
}
|
||||
|
||||
const metadataProvider = new MetadataProvider();
|
||||
|
||||
/**
|
||||
* Returns the values as an array of javascript numbers
|
||||
*
|
||||
* @param element - The javascript object for the specified element in the metadata
|
||||
* @returns {*}
|
||||
*/
|
||||
const validNumber = val => {
|
||||
if (Array.isArray(val)) {
|
||||
return val.map(v => (v !== undefined ? Number(v) : v));
|
||||
} else {
|
||||
return val !== undefined ? Number(val) : val;
|
||||
}
|
||||
};
|
||||
|
||||
export default metadataProvider;
|
||||
|
||||
const WADO_IMAGE_LOADER_TAGS = {
|
||||
@@ -414,6 +457,7 @@ const WADO_IMAGE_LOADER_TAGS = {
|
||||
SOP_COMMON_MODULE: 'sopCommonModule',
|
||||
PET_ISOTOPE_MODULE: 'petIsotopeModule',
|
||||
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
|
||||
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
|
||||
|
||||
// react-cornerstone-viewport specifc
|
||||
PATIENT_MODULE: 'patientModule',
|
||||
|
||||
@@ -3,21 +3,52 @@ import windowLevelPresets from './windowLevelPresets';
|
||||
/*
|
||||
* Supported Keys: https://craig.is/killing/mice
|
||||
*/
|
||||
export default [
|
||||
{ commandName: 'setToolActive', commandOptions: { toolName: 'Zoom' }, label: 'Zoom', keys: ['z'], isEditable: true },
|
||||
{ commandName: 'scaleUpViewport', label: 'Zoom In', keys: ['+'], isEditable: true },
|
||||
{ commandName: 'scaleDownViewport', label: 'Zoom Out', keys: ['-'], isEditable: true },
|
||||
{ commandName: 'fitViewportToWindow', label: 'Zoom to Fit', keys: ['='], isEditable: true },
|
||||
{ commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'], isEditable: true },
|
||||
{ commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'], isEditable: true },
|
||||
const bindings = [
|
||||
{
|
||||
commandName: 'flipViewportVertical',
|
||||
commandName: 'setToolActive',
|
||||
commandOptions: { toolName: 'Zoom' },
|
||||
label: 'Zoom',
|
||||
keys: ['z'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'scaleUpViewport',
|
||||
label: 'Zoom In',
|
||||
keys: ['+'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'scaleDownViewport',
|
||||
label: 'Zoom Out',
|
||||
keys: ['-'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'fitViewportToWindow',
|
||||
label: 'Zoom to Fit',
|
||||
keys: ['='],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'rotateViewportCW',
|
||||
label: 'Rotate Right',
|
||||
keys: ['r'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'rotateViewportCCW',
|
||||
label: 'Rotate Left',
|
||||
keys: ['l'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'flipViewportHorizontal',
|
||||
label: 'Flip Horizontally',
|
||||
keys: ['h'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'flipViewportHorizontal',
|
||||
commandName: 'flipViewportVertical',
|
||||
label: 'Flip Vertically',
|
||||
keys: ['v'],
|
||||
isEditable: true,
|
||||
@@ -57,11 +88,36 @@ export default [
|
||||
keys: ['pagedown'],
|
||||
isEditable: true,
|
||||
},
|
||||
{ commandName: 'nextImage', label: 'Next Image', keys: ['down'], isEditable: true },
|
||||
{ commandName: 'previousImage', label: 'Previous Image', keys: ['up'], isEditable: true },
|
||||
{ commandName: 'firstImage', label: 'First Image', keys: ['home'], isEditable: true },
|
||||
{ commandName: 'lastImage', label: 'Last Image', keys: ['end'], isEditable: true },
|
||||
{ commandName: 'resetViewport', label: 'Reset', keys: ['space'], isEditable: true },
|
||||
{
|
||||
commandName: 'nextImage',
|
||||
label: 'Next Image',
|
||||
keys: ['down'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'previousImage',
|
||||
label: 'Previous Image',
|
||||
keys: ['up'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'firstImage',
|
||||
label: 'First Image',
|
||||
keys: ['home'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'lastImage',
|
||||
label: 'Last Image',
|
||||
keys: ['end'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'resetViewport',
|
||||
label: 'Reset',
|
||||
keys: ['space'],
|
||||
isEditable: true,
|
||||
},
|
||||
{
|
||||
commandName: 'cancelMeasurement',
|
||||
label: 'Cancel Cornerstone Measurement',
|
||||
@@ -122,3 +178,5 @@ export default [
|
||||
keys: ['9'],
|
||||
},
|
||||
];
|
||||
|
||||
export default bindings;
|
||||
@@ -99,17 +99,28 @@ export default class ExtensionManager {
|
||||
*
|
||||
* @param {Object[]} extensions - Array of extensions
|
||||
*/
|
||||
registerExtensions = (extensions, dataSources = []) => {
|
||||
extensions.forEach(extension => {
|
||||
registerExtensions = async (extensions, dataSources = []) => {
|
||||
// Todo: we ideally should be able to run registrations in parallel
|
||||
// but currently since some extensions need to be registered before
|
||||
// others, we need to run them sequentially. We need a postInit hook
|
||||
// to avoid this sequential async registration
|
||||
for (const extension of extensions) {
|
||||
const hasConfiguration = Array.isArray(extension);
|
||||
|
||||
if (hasConfiguration) {
|
||||
const [ohifExtension, configuration] = extension;
|
||||
this.registerExtension(ohifExtension, configuration, dataSources);
|
||||
} else {
|
||||
this.registerExtension(extension, {}, dataSources);
|
||||
try {
|
||||
if (hasConfiguration) {
|
||||
const [ohifExtension, configuration] = extension;
|
||||
await this.registerExtension(
|
||||
ohifExtension,
|
||||
configuration,
|
||||
dataSources
|
||||
);
|
||||
} else {
|
||||
await this.registerExtension(extension, {}, dataSources);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -118,7 +129,11 @@ export default class ExtensionManager {
|
||||
* @param {Object} extension
|
||||
* @param {Object} configuration
|
||||
*/
|
||||
registerExtension = (extension, configuration = {}, dataSources = []) => {
|
||||
registerExtension = async (
|
||||
extension,
|
||||
configuration = {},
|
||||
dataSources = []
|
||||
) => {
|
||||
if (!extension) {
|
||||
throw new Error('Attempting to register a null/undefined extension.');
|
||||
}
|
||||
@@ -140,10 +155,11 @@ export default class ExtensionManager {
|
||||
|
||||
// preRegistrationHook
|
||||
if (extension.preRegistration) {
|
||||
extension.preRegistration({
|
||||
await extension.preRegistration({
|
||||
servicesManager: this._servicesManager,
|
||||
commandsManager: this._commandsManager,
|
||||
hotkeysManager: this._hotkeysManager,
|
||||
extensionManager: this,
|
||||
appConfig: this._appConfig,
|
||||
configuration,
|
||||
});
|
||||
@@ -186,6 +202,7 @@ export default class ExtensionManager {
|
||||
case MODULE_TYPES.SOP_CLASS_HANDLER:
|
||||
case MODULE_TYPES.CONTEXT:
|
||||
case MODULE_TYPES.LAYOUT_TEMPLATE:
|
||||
case MODULE_TYPES.UTILITY:
|
||||
case MODULE_TYPES.HANGING_PROTOCOL:
|
||||
// Default for most extension points,
|
||||
// Just adds each entry ready for consumption by mode.
|
||||
@@ -299,14 +316,6 @@ export default class ExtensionManager {
|
||||
});
|
||||
}
|
||||
|
||||
_initHangingProtocolModule(extensionModule, extensionId) {
|
||||
extensionModule.forEach(element => {
|
||||
this.modulesMap[
|
||||
`${extensionId}.${MODULE_TYPES.HANGING_PROTOCOL}.${element.name}`
|
||||
] = element;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @private
|
||||
|
||||
@@ -40,18 +40,18 @@ describe('ExtensionManager.js', () => {
|
||||
});
|
||||
|
||||
describe('registerExtensions()', () => {
|
||||
it('calls registerExtension() for each extension', () => {
|
||||
it('calls registerExtension() for each extension', async () => {
|
||||
extensionManager.registerExtension = jest.fn();
|
||||
|
||||
// SUT
|
||||
const fakeExtensions = [{ one: '1' }, { two: '2' }, { three: '3 ' }];
|
||||
extensionManager.registerExtensions(fakeExtensions);
|
||||
await extensionManager.registerExtensions(fakeExtensions);
|
||||
|
||||
// Assert
|
||||
expect(extensionManager.registerExtension.mock.calls.length).toBe(3);
|
||||
});
|
||||
|
||||
it('calls registerExtension() for each extension passing its configuration if tuple', () => {
|
||||
it('calls registerExtension() for each extension passing its configuration if tuple', async () => {
|
||||
const fakeConfiguration = { testing: true };
|
||||
extensionManager.registerExtension = jest.fn();
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('ExtensionManager.js', () => {
|
||||
[{ two: '2' }, fakeConfiguration],
|
||||
{ three: '3 ' },
|
||||
];
|
||||
extensionManager.registerExtensions(fakeExtensions);
|
||||
await extensionManager.registerExtensions(fakeExtensions);
|
||||
|
||||
// Assert
|
||||
expect(extensionManager.registerExtension.mock.calls[1][1]).toEqual(
|
||||
@@ -91,30 +91,35 @@ describe('ExtensionManager.js', () => {
|
||||
expect(extension.preRegistration.mock.calls[0][0]).toEqual({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
configuration: extensionConfiguration,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a warning if the extension is null or undefined', () => {
|
||||
it('logs a warning if the extension is null or undefined', async () => {
|
||||
const undefinedExtension = undefined;
|
||||
const nullExtension = null;
|
||||
|
||||
expect(() => {
|
||||
extensionManager.registerExtension(undefinedExtension);
|
||||
}).toThrow('Attempting to register a null/undefined extension.');
|
||||
await expect(
|
||||
extensionManager.registerExtension(undefinedExtension)
|
||||
).rejects.toThrow(
|
||||
new Error('Attempting to register a null/undefined extension.')
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
extensionManager.registerExtension(nullExtension);
|
||||
}).toThrow('Attempting to register a null/undefined extension.');
|
||||
await expect(
|
||||
extensionManager.registerExtension(nullExtension)
|
||||
).rejects.toThrow(
|
||||
new Error('Attempting to register a null/undefined extension.')
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a warning if the extension does not have an id', () => {
|
||||
it('logs a warning if the extension does not have an id', async () => {
|
||||
const extensionWithoutId = {};
|
||||
|
||||
expect(() => {
|
||||
extensionManager.registerExtension(extensionWithoutId);
|
||||
}).toThrow(new Error('Extension ID not set'));
|
||||
await expect(
|
||||
extensionManager.registerExtension(extensionWithoutId)
|
||||
).rejects.toThrow(new Error('Extension ID not set'));
|
||||
});
|
||||
|
||||
it('tracks which extensions have been registered', () => {
|
||||
@@ -153,7 +158,7 @@ describe('ExtensionManager.js', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an error if an exception is thrown while retrieving a module', () => {
|
||||
it('logs an error if an exception is thrown while retrieving a module', async () => {
|
||||
const extensionWithBadModule = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: () => {
|
||||
@@ -161,9 +166,9 @@ describe('ExtensionManager.js', () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
extensionManager.registerExtension(extensionWithBadModule);
|
||||
}).toThrow();
|
||||
await expect(
|
||||
extensionManager.registerExtension(extensionWithBadModule)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('successfully passes dependencies to each module along with extension configuration', () => {
|
||||
@@ -194,7 +199,7 @@ describe('ExtensionManager.js', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('successfully registers a module for each module type', () => {
|
||||
it('successfully registers a module for each module type', async () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: () => {
|
||||
@@ -224,9 +229,12 @@ describe('ExtensionManager.js', () => {
|
||||
getContextModule: () => {
|
||||
return [{}];
|
||||
},
|
||||
getUtilityModule: () => {
|
||||
return [{}];
|
||||
},
|
||||
};
|
||||
|
||||
extensionManager.registerExtension(extension);
|
||||
await extensionManager.registerExtension(extension);
|
||||
|
||||
// Registers 1 module per module type
|
||||
Object.keys(extensionManager.modules).forEach(moduleType => {
|
||||
|
||||
@@ -8,4 +8,5 @@ export default {
|
||||
CONTEXT: 'contextModule',
|
||||
LAYOUT_TEMPLATE: 'layoutTemplateModule',
|
||||
HANGING_PROTOCOL: 'hangingProtocolModule',
|
||||
UTILITY: 'utilityModule',
|
||||
};
|
||||
@@ -3,7 +3,6 @@ import { ServicesManager } from './services';
|
||||
import classes, { CommandsManager, HotkeysManager } from './classes/';
|
||||
|
||||
import DICOMWeb from './DICOMWeb';
|
||||
import DICOMSR from './DICOMSR';
|
||||
import errorHandler from './errorHandler.js';
|
||||
import log from './log.js';
|
||||
import object from './object.js';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
HangingProtocolService,
|
||||
pubSubServiceInterface,
|
||||
UserAuthenticationService,
|
||||
SegmentationService,
|
||||
} from './services';
|
||||
|
||||
import IWebApiDataSource from './DataSources/IWebApiDataSource';
|
||||
@@ -54,7 +54,6 @@ const OHIF = {
|
||||
object,
|
||||
log,
|
||||
DICOMWeb,
|
||||
DICOMSR,
|
||||
viewer: {},
|
||||
//
|
||||
CineService,
|
||||
@@ -68,6 +67,7 @@ const OHIF = {
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
UserAuthenticationService,
|
||||
SegmentationService,
|
||||
IWebApiDataSource,
|
||||
DicomMetadataStore,
|
||||
pubSubServiceInterface,
|
||||
@@ -91,7 +91,6 @@ export {
|
||||
object,
|
||||
log,
|
||||
DICOMWeb,
|
||||
DICOMSR,
|
||||
//
|
||||
CineService,
|
||||
UIDialogService,
|
||||
@@ -104,6 +103,7 @@ export {
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
UserAuthenticationService,
|
||||
SegmentationService,
|
||||
IWebApiDataSource,
|
||||
DicomMetadataStore,
|
||||
pubSubServiceInterface,
|
||||
|
||||
@@ -21,7 +21,6 @@ describe('Top level exports', () => {
|
||||
'object',
|
||||
'log',
|
||||
'DICOMWeb',
|
||||
'DICOMSR',
|
||||
'OHIF',
|
||||
//
|
||||
'CineService',
|
||||
@@ -33,6 +32,7 @@ describe('Top level exports', () => {
|
||||
'MeasurementService',
|
||||
'ToolBarService',
|
||||
'ViewportGridService',
|
||||
'SegmentationService',
|
||||
'HangingProtocolService',
|
||||
'UserAuthenticationService',
|
||||
'IWebApiDataSource',
|
||||
|
||||
@@ -5,15 +5,17 @@ const publicAPI = {
|
||||
getState: _getState,
|
||||
setCine: _setCine,
|
||||
setIsCineEnabled: _setIsCineEnabled,
|
||||
playClip: _playClip,
|
||||
stopClip: _stopClip,
|
||||
setServiceImplementation,
|
||||
};
|
||||
|
||||
const serviceImplementation = {
|
||||
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
|
||||
_setCine: () =>
|
||||
console.warn('setCine() NOT IMPLEMENTED'),
|
||||
_setIsCineEnabled: () =>
|
||||
console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
|
||||
_setCine: () => console.warn('setCine() NOT IMPLEMENTED'),
|
||||
_playClip: () => console.warn('playClip() NOT IMPLEMENTED'),
|
||||
_stopClip: () => console.warn('stopClip() NOT IMPLEMENTED'),
|
||||
_setIsCineEnabled: () => console.warn('setIsCineEnabled() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
function _getState() {
|
||||
@@ -28,10 +30,20 @@ function _setIsCineEnabled(isCineEnabled) {
|
||||
return serviceImplementation._setIsCineEnabled(isCineEnabled);
|
||||
}
|
||||
|
||||
function _playClip(element, playClipOptions) {
|
||||
return serviceImplementation._playClip(element, playClipOptions);
|
||||
}
|
||||
|
||||
function _stopClip(element) {
|
||||
return serviceImplementation._stopClip(element);
|
||||
}
|
||||
|
||||
function setServiceImplementation({
|
||||
getState: getStateImplementation,
|
||||
setCine: setCineImplementation,
|
||||
setIsCineEnabled: setIsCineEnabledImplementation,
|
||||
playClip: playClipImplementation,
|
||||
stopClip: stopClipImplementation,
|
||||
}) {
|
||||
if (getStateImplementation) {
|
||||
serviceImplementation._getState = getStateImplementation;
|
||||
@@ -42,6 +54,14 @@ function setServiceImplementation({
|
||||
if (setIsCineEnabledImplementation) {
|
||||
serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
|
||||
}
|
||||
|
||||
if (playClipImplementation) {
|
||||
serviceImplementation._playClip = playClipImplementation;
|
||||
}
|
||||
|
||||
if (stopClipImplementation) {
|
||||
serviceImplementation._stopClip = stopClipImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,33 +1,56 @@
|
||||
import dcmjs from 'dcmjs'
|
||||
import dcmjs from 'dcmjs';
|
||||
|
||||
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
import createStudyMetadata from './createStudyMetadata';
|
||||
import EVENTS from './EVENTS';
|
||||
|
||||
const EVENTS = {
|
||||
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
|
||||
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
|
||||
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
|
||||
SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated',
|
||||
};
|
||||
|
||||
/**
|
||||
* @example
|
||||
* studies: [
|
||||
* {
|
||||
* StudyInstanceUID: string,
|
||||
* isLoaded: boolean,
|
||||
* series: [
|
||||
* {
|
||||
* Modality: string,
|
||||
* SeriesInstanceUID: string,
|
||||
* SeriesNumber: number,
|
||||
* SeriesDescription: string,
|
||||
* instances: [
|
||||
* {
|
||||
* // naturalized instance metadata
|
||||
* SOPInstanceUID: string,
|
||||
* SOPClassUID: string,
|
||||
* Rows: number,
|
||||
* Columns: number,
|
||||
* PatientSex: string,
|
||||
* Modality: string,
|
||||
* InstanceNumber: string,
|
||||
* },
|
||||
* {
|
||||
* // instance 2
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* {
|
||||
* // series 2
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* ],
|
||||
*/
|
||||
const _model = {
|
||||
studies: [],
|
||||
// studies: [{
|
||||
// seriesLists: [
|
||||
// {
|
||||
// // Series in study from dicom web server 1 (or different backend 1)
|
||||
// series: [{
|
||||
// instances: [{
|
||||
// ...instanceMetadata // Naturalized DICOM.
|
||||
// }],
|
||||
// ...seriesMetadata
|
||||
// }],
|
||||
// clientName
|
||||
// },
|
||||
// {
|
||||
// // Series in study from dicom web server 2 (or different backend 2)
|
||||
// },
|
||||
// ],
|
||||
// ...studyMetadata,
|
||||
// }]
|
||||
};
|
||||
|
||||
function _getStudyInstanceUIDs() {
|
||||
return _model.studies.map(aStudy => aStudy.StudyInstanceUID)
|
||||
return _model.studies.map(aStudy => aStudy.StudyInstanceUID);
|
||||
}
|
||||
|
||||
function _getStudy(StudyInstanceUID) {
|
||||
@@ -60,7 +83,7 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
|
||||
);
|
||||
}
|
||||
|
||||
function _getInstanceFromImageId(imageId) {
|
||||
function _getInstanceByImageId(imageId) {
|
||||
for (let study of _model.studies) {
|
||||
for (let series of study.series) {
|
||||
for (let instance of series.instances) {
|
||||
@@ -72,6 +95,52 @@ function _getInstanceFromImageId(imageId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the metadata of a specific series
|
||||
* @param {*} StudyInstanceUID
|
||||
* @param {*} SeriesInstanceUID
|
||||
* @param {*} metadata metadata inform of key value pairs
|
||||
* @returns
|
||||
*/
|
||||
function _updateMetadataForSeries(
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
metadata
|
||||
) {
|
||||
const study = _getStudy(StudyInstanceUID);
|
||||
|
||||
if (!study) {
|
||||
return;
|
||||
}
|
||||
|
||||
const series = study.series.find(
|
||||
aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID
|
||||
);
|
||||
|
||||
const { instances } = series;
|
||||
// update all instances metadata for this series with the new metadata
|
||||
instances.forEach(instance => {
|
||||
Object.keys(metadata).forEach(key => {
|
||||
// if metadata[key] is an object, we need to merge it with the existing
|
||||
// metadata of the instance
|
||||
if (typeof metadata[key] === 'object') {
|
||||
instance[key] = { ...instance[key], ...metadata[key] };
|
||||
}
|
||||
// otherwise, we just replace the existing metadata with the new one
|
||||
else {
|
||||
instance[key] = metadata[key];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// broadcast the series updated event
|
||||
this._broadcastEvent(EVENTS.SERIES_UPDATED, {
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
madeInClient: true,
|
||||
});
|
||||
}
|
||||
|
||||
const BaseImplementation = {
|
||||
EVENTS,
|
||||
listeners: {},
|
||||
@@ -80,7 +149,9 @@ const BaseImplementation = {
|
||||
|
||||
// If Arraybuffer, parse to DICOMJSON before naturalizing.
|
||||
if (dicomJSONDatasetOrP10ArrayBuffer instanceof ArrayBuffer) {
|
||||
const dicomData = dcmjs.data.DicomMessage.readFile(dicomJSONDatasetOrP10ArrayBuffer);
|
||||
const dicomData = dcmjs.data.DicomMessage.readFile(
|
||||
dicomJSONDatasetOrP10ArrayBuffer
|
||||
);
|
||||
|
||||
dicomJSONDataset = dicomData.dict;
|
||||
} else {
|
||||
@@ -140,6 +211,7 @@ const BaseImplementation = {
|
||||
let study = _getStudy(StudyInstanceUID);
|
||||
if (!study) {
|
||||
study = createStudyMetadata(StudyInstanceUID);
|
||||
study.StudyDescription = seriesSummaryMetadata[0].StudyDescription;
|
||||
_model.studies.push(study);
|
||||
}
|
||||
|
||||
@@ -179,15 +251,18 @@ const BaseImplementation = {
|
||||
getStudy: _getStudy,
|
||||
getSeries: _getSeries,
|
||||
getInstance: _getInstance,
|
||||
getInstanceFromImageId: _getInstanceFromImageId,
|
||||
getInstanceByImageId: _getInstanceByImageId,
|
||||
updateMetadataForSeries: _updateMetadataForSeries,
|
||||
};
|
||||
|
||||
const DicomMetadataStore = Object.assign(
|
||||
// get study
|
||||
|
||||
// iterate over all series
|
||||
|
||||
{},
|
||||
BaseImplementation,
|
||||
pubSubServiceInterface
|
||||
);
|
||||
|
||||
|
||||
export { DicomMetadataStore };
|
||||
export default DicomMetadataStore;
|
||||
@@ -1,7 +0,0 @@
|
||||
const EVENTS = {
|
||||
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
|
||||
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
|
||||
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
|
||||
};
|
||||
|
||||
export default EVENTS;
|
||||
@@ -3,6 +3,7 @@ import createSeriesMetadata from './createSeriesMetadata';
|
||||
function createStudyMetadata(StudyInstanceUID) {
|
||||
return {
|
||||
StudyInstanceUID,
|
||||
StudyDescription: '',
|
||||
isLoaded: false,
|
||||
series: [],
|
||||
/**
|
||||
@@ -10,7 +11,7 @@ function createStudyMetadata(StudyInstanceUID) {
|
||||
* @param {object} instance
|
||||
* @returns {bool} true if series were added; false if series already exist
|
||||
*/
|
||||
addInstanceToSeries: function (instance) {
|
||||
addInstanceToSeries: function(instance) {
|
||||
const { SeriesInstanceUID } = instance;
|
||||
const existingSeries = this.series.find(
|
||||
s => s.SeriesInstanceUID === SeriesInstanceUID
|
||||
@@ -29,7 +30,7 @@ function createStudyMetadata(StudyInstanceUID) {
|
||||
* @param {string} instances[].SeriesInstanceUID
|
||||
* @returns {bool} true if series were added; false if series already exist
|
||||
*/
|
||||
addInstancesToSeries: function (instances) {
|
||||
addInstancesToSeries: function(instances) {
|
||||
const { SeriesInstanceUID } = instances[0];
|
||||
const existingSeries = this.series.find(
|
||||
s => s.SeriesInstanceUID === SeriesInstanceUID
|
||||
@@ -43,7 +44,7 @@ function createStudyMetadata(StudyInstanceUID) {
|
||||
}
|
||||
},
|
||||
|
||||
setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) {
|
||||
setSeriesMetadata: function(SeriesInstanceUID, seriesMetadata) {
|
||||
let existingSeries = this.series.find(
|
||||
s => s.SeriesInstanceUID === SeriesInstanceUID
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ const findInstance = (instance, displaySets) => {
|
||||
if (findInSet(instance, displayset.others)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default class DisplaySetService {
|
||||
constructor() {
|
||||
@@ -89,6 +89,20 @@ export default class DisplaySetService {
|
||||
return displaySet;
|
||||
}
|
||||
|
||||
setDisplaySetMetadataInvalidated(displaySetInstanceUID) {
|
||||
const displaySet = this.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
if (!displaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
// broadcast event to update listeners with the new displaySets
|
||||
this._broadcastEvent(
|
||||
EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
|
||||
displaySetInstanceUID
|
||||
);
|
||||
}
|
||||
|
||||
deleteDisplaySet(displaySetInstanceUID) {
|
||||
const { activeDisplaySets } = this;
|
||||
|
||||
@@ -164,7 +178,7 @@ export default class DisplaySetService {
|
||||
}
|
||||
|
||||
// TODO: This is tricky. How do we know we're not resetting to the same/existing DSs?
|
||||
// TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent uneccessary broadcasts?
|
||||
// TODO: This is likely run anytime we touch DicomMetadataStore. How do we prevent unnecessary broadcasts?
|
||||
if (displaySetsAdded && displaySetsAdded.length) {
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
|
||||
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
|
||||
@@ -213,10 +227,14 @@ export default class DisplaySetService {
|
||||
this._addDisplaySetsToCache(displaySets);
|
||||
this._addActiveDisplaySets(displaySets);
|
||||
|
||||
instances = instances.filter(instance => !findInstance(instance, displaySets))
|
||||
instances = instances.filter(
|
||||
instance => !findInstance(instance, displaySets)
|
||||
);
|
||||
}
|
||||
|
||||
allDisplaySets = allDisplaySets ? [...allDisplaySets, ...displaySets] : displaySets;
|
||||
allDisplaySets = allDisplaySets
|
||||
? [...allDisplaySets, ...displaySets]
|
||||
: displaySets;
|
||||
|
||||
if (!instances.length) return allDisplaySets;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ const EVENTS = {
|
||||
DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded',
|
||||
DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged',
|
||||
DISPLAY_SETS_REMOVED: 'event::displaySetService:displaySetsRemoved',
|
||||
DISPLAY_SET_SERIES_METADATA_INVALIDATED:
|
||||
'event::displaySetService:displaySetSeriesMetadataInvalidated',
|
||||
};
|
||||
|
||||
export default EVENTS;
|
||||
@@ -1,6 +1,5 @@
|
||||
import validate from './lib/validator';
|
||||
|
||||
|
||||
/**
|
||||
* Match a Metadata instance against rules using Validate.js for validation.
|
||||
* @param {InstanceMetadata} metadataInstance Metadata instance object
|
||||
@@ -36,7 +35,13 @@ const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
|
||||
|
||||
// Create a single attribute object to be validated, since metadataInstance is an
|
||||
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
|
||||
const attributeValue = metadataInstance[attribute];
|
||||
let attributeValue = metadataInstance[attribute];
|
||||
if (attributeValue === undefined) {
|
||||
if (attribute === 'NumberOfStudyRelatedSeries') {
|
||||
attributeValue = metadataInstance.series?.length;
|
||||
}
|
||||
// Add other computable values such as modalities in study
|
||||
}
|
||||
const attributeMap = {
|
||||
[attribute]: attributeValue,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
import sortBy from '../../utils/sortBy.js';
|
||||
import ProtocolEngine from './ProtocolEngine';
|
||||
@@ -6,11 +5,8 @@ import ProtocolEngine from './ProtocolEngine';
|
||||
const EVENTS = {
|
||||
STAGE_CHANGE: 'event::hanging_protocol_stage_change',
|
||||
NEW_LAYOUT: 'event::hanging_protocol_new_layout',
|
||||
};
|
||||
|
||||
const VIEWPORT_SETTING_TYPES = {
|
||||
PROPS: 'props',
|
||||
VIEWPORT: 'viewport',
|
||||
CUSTOM_IMAGE_LOAD_PERFORMED:
|
||||
'event::hanging_protocol_custom_image_load_performed',
|
||||
};
|
||||
|
||||
class HangingProtocolService {
|
||||
@@ -20,12 +16,38 @@ class HangingProtocolService {
|
||||
this.ProtocolEngine = undefined;
|
||||
this.protocol = undefined;
|
||||
this.stage = undefined;
|
||||
/**
|
||||
* An array that contains for each viewport (viewportIndex) specified in the
|
||||
* hanging protocol, an object of the form
|
||||
*
|
||||
* {
|
||||
* viewportOptions,
|
||||
* displaySetsInfo, // contains array of [ { SeriesInstanceUID, displaySetOPtions}, ... ]
|
||||
* }
|
||||
*/
|
||||
this.matchDetails = [];
|
||||
/**
|
||||
* displaySetMatchDetails = <displaySetId, match>
|
||||
* DisplaySetId is the id defined in the hangingProtocol
|
||||
* match is an object that contains information about
|
||||
*
|
||||
* {
|
||||
* SeriesInstanceUID,
|
||||
* StudyInstanceUID,
|
||||
* matchDetails,
|
||||
* matchingScore,
|
||||
* sortingInfo
|
||||
* }
|
||||
*/
|
||||
this.displaySetMatchDetails = new Map();
|
||||
this.hpAlreadyApplied = [];
|
||||
this.studies = [];
|
||||
this.customViewportSettings = [];
|
||||
this.customAttributeRetrievalCallbacks = {};
|
||||
this.listeners = {};
|
||||
this.registeredImageLoadStrategies = {};
|
||||
this.activeImageLoadStrategyName = null;
|
||||
this.customImageLoadPerformed = false;
|
||||
Object.defineProperty(this, 'EVENTS', {
|
||||
value: EVENTS,
|
||||
writable: false,
|
||||
@@ -43,6 +65,10 @@ class HangingProtocolService {
|
||||
// this.ProtocolEngine.reset()
|
||||
}
|
||||
|
||||
getDisplaySetsMatchDetails() {
|
||||
return this.displaySetMatchDetails;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return [this.matchDetails, this.hpAlreadyApplied];
|
||||
}
|
||||
@@ -54,7 +80,7 @@ class HangingProtocolService {
|
||||
addProtocols(protocols) {
|
||||
protocols.forEach(protocol => {
|
||||
if (this.protocols.indexOf(protocol) === -1) {
|
||||
this.protocols.push(protocol);
|
||||
this.protocols.push(this._validateProtocol(protocol));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -81,20 +107,37 @@ class HangingProtocolService {
|
||||
this._setProtocol(protocol);
|
||||
}
|
||||
|
||||
setHangingProtocolAppliedForViewport(i) {
|
||||
this.hpAlreadyApplied[i] = true;
|
||||
/**
|
||||
* Returns true, if the hangingProtocol has a custom loading strategy for the images
|
||||
* and its callback has been added to the HangingProtocolService
|
||||
* @returns {boolean} true
|
||||
*/
|
||||
hasCustomImageLoadStrategy() {
|
||||
return (
|
||||
this.activeImageLoadStrategyName !== null &&
|
||||
this.registeredImageLoadStrategies[
|
||||
this.activeImageLoadStrategyName
|
||||
] instanceof Function
|
||||
);
|
||||
}
|
||||
|
||||
getCustomImageLoadPerformed() {
|
||||
return this.customImageLoadPerformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom setting that can be chosen in the HangingProtocol UI and applied to a Viewport
|
||||
*
|
||||
* @param settingId The ID used to refer to the setting (e.g. 'displayCADMarkers')
|
||||
* @param settingName The name of the setting to be displayed (e.g. 'Display CAD Markers')
|
||||
* @param options
|
||||
* @param callback A function to be run after a viewport is rendered with a series
|
||||
* Set the strategy callback for loading images to the HangingProtocolService
|
||||
* @param {string} name strategy name
|
||||
* @param {Function} callback image loader callback
|
||||
*/
|
||||
addCustomViewportSetting(...params) {
|
||||
this.customViewportSettings.push(...params);
|
||||
registerImageLoadStrategy(name, callback) {
|
||||
if (callback instanceof Function && name) {
|
||||
this.registeredImageLoadStrategies[name] = callback;
|
||||
}
|
||||
}
|
||||
|
||||
setHangingProtocolAppliedForViewport(i) {
|
||||
this.hpAlreadyApplied[i] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,12 +177,82 @@ class HangingProtocolService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the callback function for the custom loading strategy for the images
|
||||
* if no strategy is set, the default strategy is used
|
||||
*/
|
||||
runImageLoadStrategy(data) {
|
||||
const loader = this.registeredImageLoadStrategies[
|
||||
this.activeImageLoadStrategyName
|
||||
];
|
||||
const loadedData = loader({
|
||||
data,
|
||||
displaySetsMatchDetails: this.getDisplaySetsMatchDetails(),
|
||||
matchDetails: this.matchDetails,
|
||||
});
|
||||
|
||||
// if loader successfully re-arranged the data with the custom strategy
|
||||
// and returned the new props, then broadcast them
|
||||
if (!loadedData) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.customImageLoadPerformed = true;
|
||||
this._broadcastChange(this.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, loadedData);
|
||||
}
|
||||
|
||||
_validateProtocol(protocol) {
|
||||
protocol.id = protocol.id || protocol.name;
|
||||
// Automatically compute some number of attributes if they
|
||||
// aren't present. Makes defining new HPs easier.
|
||||
protocol.name = protocol.name || protocol.id;
|
||||
const { stages } = protocol;
|
||||
|
||||
// Generate viewports automatically as required.
|
||||
stages.forEach(stage => {
|
||||
if (!stage.viewports) {
|
||||
stage.viewports = [];
|
||||
const { rows, columns } = stage.viewportStructure.properties;
|
||||
|
||||
for (let i = 0; i < rows * columns; i++) {
|
||||
stage.viewports.push({
|
||||
viewportOptions: {},
|
||||
displaySets: [],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
stage.viewports.forEach(viewport => {
|
||||
viewport.viewportOptions = viewport.viewportOptions || {};
|
||||
if (!viewport.displaySets) {
|
||||
viewport.displaySets = [];
|
||||
} else {
|
||||
viewport.displaySets.forEach(displaySet => {
|
||||
displaySet.options = displaySet.options || {};
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return protocol;
|
||||
}
|
||||
|
||||
_setProtocol(protocol) {
|
||||
// TODO: Add proper Protocol class to validate the protocols
|
||||
// which are entered manually
|
||||
this.stage = 0;
|
||||
this.protocol = protocol;
|
||||
this._updateViewports(protocol);
|
||||
const { imageLoadStrategy } = protocol;
|
||||
if (imageLoadStrategy) {
|
||||
// check if the imageLoadStrategy is a valid strategy
|
||||
if (
|
||||
this.registeredImageLoadStrategies[imageLoadStrategy] instanceof
|
||||
Function
|
||||
) {
|
||||
this.activeImageLoadStrategyName = imageLoadStrategy;
|
||||
}
|
||||
}
|
||||
this._updateViewports();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +286,9 @@ class HangingProtocolService {
|
||||
return;
|
||||
}
|
||||
|
||||
// reset displaySetMatchDetails
|
||||
this.displaySetMatchDetails = new Map();
|
||||
|
||||
// Retrieve the current stage
|
||||
const stageModel = this._getCurrentStageModel();
|
||||
|
||||
@@ -182,18 +298,14 @@ class HangingProtocolService {
|
||||
!stageModel ||
|
||||
!stageModel.viewportStructure ||
|
||||
!stageModel.viewports ||
|
||||
!stageModel.displaySets ||
|
||||
!stageModel.viewports.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Retrieve the layoutTemplate associated with the current display set's viewport structure
|
||||
// If no such template name exists, stop here.
|
||||
// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
|
||||
const layoutTemplateName = 'gridLayout';
|
||||
if (!layoutTemplateName) {
|
||||
return;
|
||||
}
|
||||
this.customImageLoadPerformed = false;
|
||||
const { type: layoutType } = stageModel.viewportStructure;
|
||||
|
||||
// Retrieve the properties associated with the current display set's viewport structure template
|
||||
// If no such layout properties exist, stop here.
|
||||
@@ -202,97 +314,70 @@ class HangingProtocolService {
|
||||
return;
|
||||
}
|
||||
|
||||
const { columns: numCols, rows: numRows } = layoutProps;
|
||||
const { columns: numCols, rows: numRows, layoutOptions = [] } = layoutProps;
|
||||
|
||||
this._broadcastChange(this.EVENTS.NEW_LAYOUT, {
|
||||
layoutType,
|
||||
numRows,
|
||||
numCols,
|
||||
layoutOptions,
|
||||
});
|
||||
|
||||
// Empty the matchDetails associated with the ProtocolEngine.
|
||||
// This will be used to store the pass/fail details and score
|
||||
// for each of the viewport matching procedures
|
||||
// Matching the displaySets
|
||||
// Note: this is happening before displaySets are created. Here, displaySet
|
||||
// only contains the information of the id of the displaySet to be matched
|
||||
// based on some rules
|
||||
stageModel.displaySets.forEach(displaySet => {
|
||||
const { bestMatch } = this._matchImages(displaySet);
|
||||
this.displaySetMatchDetails.set(displaySet.id, bestMatch);
|
||||
});
|
||||
|
||||
// Loop through each viewport
|
||||
stageModel.viewports.forEach((viewport, viewportIndex) => {
|
||||
const { viewportOptions } = viewport;
|
||||
this.hpAlreadyApplied.push(false);
|
||||
const details = this._matchImages(viewport);
|
||||
|
||||
let currentMatch = details.bestMatch;
|
||||
// DisplaySets for the viewport, Note: this is not the actual displaySet,
|
||||
// but it is a info to locate the displaySet from the displaySetService
|
||||
let displaySetsInfo = [];
|
||||
viewport.displaySets.forEach(({ id, options: displaySetOptions }) => {
|
||||
const viewportDisplaySet = this.displaySetMatchDetails.get(id);
|
||||
|
||||
const currentViewportData = {
|
||||
viewportIndex,
|
||||
SeriesInstanceUID: currentMatch && currentMatch.SeriesInstanceUID,
|
||||
if (viewportDisplaySet) {
|
||||
const { SeriesInstanceUID } = viewportDisplaySet;
|
||||
|
||||
const displaySetInfo = {
|
||||
SeriesInstanceUID,
|
||||
displaySetOptions,
|
||||
};
|
||||
|
||||
displaySetsInfo.push(displaySetInfo);
|
||||
} else {
|
||||
console.warn(
|
||||
`
|
||||
The hanging protocol viewport is requesting to display ${id} displaySet that is not
|
||||
matched based on the provided criteria (e.g. matching rules).
|
||||
`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.matchDetails[viewportIndex] = {
|
||||
viewportOptions,
|
||||
displaySetsInfo,
|
||||
};
|
||||
|
||||
// Viewport Settings
|
||||
//
|
||||
// protocol defined callback
|
||||
const protocolCallbacks = viewport.viewportSettings.filter(
|
||||
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
|
||||
);
|
||||
// manually added callback
|
||||
const customCallbacks = this.customViewportSettings.filter(
|
||||
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
|
||||
);
|
||||
const callbacks = protocolCallbacks.concat(customCallbacks);
|
||||
|
||||
// if we have callbacks to applied at the app level or at the HP level
|
||||
if (callbacks.length) {
|
||||
currentViewportData.renderedCallback = (element, ToolBarService) => {
|
||||
callbacks.forEach(setting => {
|
||||
const { commandName, options } = setting;
|
||||
options.viewportIndex = viewportIndex;
|
||||
options.element = element;
|
||||
// Toolbar service to handle tool activation
|
||||
if (commandName === 'setToolActive') {
|
||||
ToolBarService.recordInteraction(options);
|
||||
return;
|
||||
}
|
||||
// other commands
|
||||
this._commandsManager.runCommand(commandName, options);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// initial viewport settings defined by protocol
|
||||
const protocolInitialViewport = viewport.viewportSettings.filter(
|
||||
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
|
||||
);
|
||||
// custom added initial viewport settings
|
||||
const customInitialViewport = this.customViewportSettings.filter(
|
||||
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
|
||||
);
|
||||
// TODO: conflict might happen between protocol and custom viewport settings
|
||||
const viewportSettings = protocolInitialViewport.concat(
|
||||
customInitialViewport
|
||||
);
|
||||
|
||||
if (viewportSettings.length) {
|
||||
const initialViewport = {};
|
||||
viewportSettings.forEach(setting => {
|
||||
const { options } = setting;
|
||||
if (!options) return;
|
||||
// Do not manipulate the hp settings
|
||||
const viewportOptions = cloneDeep(options);
|
||||
Object.entries(viewportOptions).forEach(([key, value]) => {
|
||||
initialViewport[key] = value;
|
||||
});
|
||||
});
|
||||
currentViewportData.initialViewport = initialViewport;
|
||||
}
|
||||
|
||||
this.matchDetails[viewportIndex] = currentViewportData;
|
||||
});
|
||||
}
|
||||
|
||||
// Match images given a list of Studies and a Viewport's image matching reqs
|
||||
_matchImages(viewport) {
|
||||
_matchImages(displaySet) {
|
||||
console.log('ProtocolEngine::matchImages');
|
||||
|
||||
// TODO: matching is applied on study and series level, instance
|
||||
// level matching needs to be added in future
|
||||
|
||||
const { studyMatchingRules, seriesMatchingRules } = viewport;
|
||||
// Todo: handle fusion viewports by not taking the first displaySet rule for the viewport
|
||||
const { studyMatchingRules, seriesMatchingRules } = displaySet;
|
||||
|
||||
const matchingScores = [];
|
||||
let highestStudyMatchingScore = 0;
|
||||
@@ -460,6 +545,7 @@ class HangingProtocolService {
|
||||
* within the measurement service and the source needs to update.
|
||||
* @return void
|
||||
*/
|
||||
// Todo: why do we have a separate broadcastChange function here?
|
||||
_broadcastChange(eventName, eventData) {
|
||||
const hasListeners = Object.keys(this.listeners).length > 0;
|
||||
const hasCallbacks = Array.isArray(this.listeners[eventName]);
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
import { HPMatcher } from './HPMatcher.js';
|
||||
import { sortByScore } from './lib/sortByScore';
|
||||
|
||||
const deafultProtocol = {
|
||||
id: 'defaultProtocol',
|
||||
locked: true,
|
||||
hasUpdatedPriorsInformation: false,
|
||||
name: 'Default',
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
modifiedDate: '2021-02-23T19:22:08.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
stages: [
|
||||
{
|
||||
id: 'nwzau7jDkEkL8djfr',
|
||||
name: 'oneByOne',
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportSettings: [],
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
},
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
};
|
||||
|
||||
export default class ProtocolEngine {
|
||||
constructor(protocols, customAttributeRetrievalCallbacks) {
|
||||
this.protocols = protocols;
|
||||
@@ -117,7 +82,7 @@ export default class ProtocolEngine {
|
||||
|
||||
/**
|
||||
* Finds the best protocols from Protocol Store, matching each protocol matching rules
|
||||
* with the given study. The best protocol are orded by score and returned in an array
|
||||
* with the given study. The best protocol are ordered by score and returned in an array
|
||||
* @param {Object} study StudyMetadata instance object
|
||||
* @return {Array} Array of match objects or an empty array if no match was found
|
||||
* Each match object has the score of the matching and the matched
|
||||
@@ -131,7 +96,10 @@ export default class ProtocolEngine {
|
||||
// We clone it so that we don't accidentally add the
|
||||
// numberOfPriorsReferenced rule to the Protocol itself.
|
||||
let rules = protocol.protocolMatchingRules.slice();
|
||||
if (!rules) {
|
||||
if (!rules || !rules.length) {
|
||||
console.warn(
|
||||
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,7 +121,7 @@ export default class ProtocolEngine {
|
||||
return [
|
||||
{
|
||||
score: 1,
|
||||
protocol: deafultProtocol,
|
||||
protocol: this.protocols.find(protocol => protocol.id === 'default'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
* Measurement schema
|
||||
*
|
||||
* @typedef {Object} Measurement
|
||||
* @property {number} id -
|
||||
* @property {number} uid -
|
||||
* @property {string} SOPInstanceUID -
|
||||
* @property {string} FrameOfReferenceUID -
|
||||
* @property {string} referenceSeriesUID -
|
||||
@@ -30,24 +30,31 @@ import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
|
||||
/* Measurement schema keys for object validation. */
|
||||
const MEASUREMENT_SCHEMA_KEYS = [
|
||||
'id',
|
||||
'uid',
|
||||
'data',
|
||||
'getReport',
|
||||
'displayText',
|
||||
'SOPInstanceUID',
|
||||
'FrameOfReferenceUID',
|
||||
'referenceStudyUID',
|
||||
'referenceSeriesUID',
|
||||
'displaySetInstanceUID',
|
||||
'label',
|
||||
'description',
|
||||
'type',
|
||||
'unit',
|
||||
'points',
|
||||
'source',
|
||||
'toolName',
|
||||
'metadata',
|
||||
// Todo: we shouldn't need to have all these here.
|
||||
'area', // TODO: Add concept names instead (descriptor)
|
||||
'mean',
|
||||
'stdDev',
|
||||
'length',
|
||||
'shortestDiameter',
|
||||
'longestDiameter',
|
||||
'text', // NOTE: There is nothing like this in SR.
|
||||
'points',
|
||||
'source',
|
||||
'cachedStats',
|
||||
];
|
||||
|
||||
const EVENTS = {
|
||||
@@ -65,10 +72,25 @@ const VALUE_TYPES = {
|
||||
POINT: 'value_type::point',
|
||||
BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here.
|
||||
ELLIPSE: 'value_type::ellipse',
|
||||
RECTANGLE: 'value_type::rectangle',
|
||||
MULTIPOINT: 'value_type::multipoint',
|
||||
CIRCLE: 'value_type::circle',
|
||||
ROI_THRESHOLD: 'value_type::roiThreshold',
|
||||
ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual',
|
||||
};
|
||||
|
||||
/**
|
||||
* MeasurementService class that supports source management and measurement management.
|
||||
* Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.)
|
||||
* The flow, is that by creating a source and mappings (annotation <-> measurement), we
|
||||
* can convert back and forth between the two. MeasurementPanel in OHIF uses the measurement service
|
||||
* to manage the measurements, and any edit to the measurements will be reflected back at the
|
||||
* library level state (e.g. cornerstone-tools, cornerstone, etc.) by converting the
|
||||
* edited measurements back to the original annotations and then updating the annotations.
|
||||
*
|
||||
* Note and Todo: We should be able to support measurements that are composed of multiple
|
||||
* annotations, but that is not the case at the moment.
|
||||
*/
|
||||
class MeasurementService {
|
||||
constructor() {
|
||||
this.sources = {};
|
||||
@@ -92,6 +114,45 @@ class MeasurementService {
|
||||
Object.assign(this, pubSubServiceInterface);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given schema to the measurement service schema list.
|
||||
* This method should be used to add custom tool schema to the measurement service.
|
||||
* @param {Array} schema schema for validation
|
||||
*/
|
||||
addMeasurementSchemaKeys(schema) {
|
||||
if (!Array.isArray(schema)) {
|
||||
schema = [schema];
|
||||
}
|
||||
|
||||
MEASUREMENT_SCHEMA_KEYS.push(...schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given valueType to the measurement service valueType object.
|
||||
* This method should be used to add custom valueType to the measurement service.
|
||||
* @param {*} valueType
|
||||
* @returns
|
||||
*/
|
||||
addValueType(valueType) {
|
||||
if (VALUE_TYPES[valueType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if valuetype is valid , and if values are strings
|
||||
if (!valueType || typeof valueType !== 'object') {
|
||||
console.warn(
|
||||
`MeasurementService: addValueType: invalid valueType: ${valueType}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.keys(valueType).forEach(key => {
|
||||
if (!VALUE_TYPES[key]) {
|
||||
VALUE_TYPES[key] = valueType[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all measurements.
|
||||
*
|
||||
@@ -106,17 +167,17 @@ class MeasurementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific measurement by its id.
|
||||
* Get specific measurement by its uid.
|
||||
*
|
||||
* @param {string} id Id of the measurement
|
||||
* @param {string} uid measurement uid
|
||||
* @return {Measurement} Measurement instance
|
||||
*/
|
||||
getMeasurement(id) {
|
||||
getMeasurement(measurementUID) {
|
||||
let measurement = null;
|
||||
const measurements = this.measurements[id];
|
||||
const measurements = this.measurements[measurementUID];
|
||||
|
||||
if (measurements && Object.keys(measurements).length > 0) {
|
||||
measurement = this.measurements[id];
|
||||
measurement = this.measurements[measurementUID];
|
||||
}
|
||||
|
||||
return measurement;
|
||||
@@ -138,69 +199,71 @@ class MeasurementService {
|
||||
throw new Error('Source version not provided.');
|
||||
}
|
||||
|
||||
const id = guid();
|
||||
// Go over all the keys inside the sources and check if the source
|
||||
// name and version matches with the existing sources.
|
||||
const sourceKeys = Object.keys(this.sources);
|
||||
|
||||
for (let i = 0; i < sourceKeys.length; i++) {
|
||||
const source = this.sources[sourceKeys[i]];
|
||||
if (source.name === name && source.version === version) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
const uid = guid();
|
||||
const source = {
|
||||
id,
|
||||
uid,
|
||||
name,
|
||||
version,
|
||||
};
|
||||
source.addOrUpdate = (definition, measurement) => {
|
||||
return this.addOrUpdate(source, definition, measurement);
|
||||
|
||||
source.annotationToMeasurement = (annotationType, annotation) => {
|
||||
return this.annotationToMeasurement(source, annotationType, annotation);
|
||||
};
|
||||
source.remove = id => {
|
||||
return this.remove(id, source);
|
||||
|
||||
source.remove = (measurementUID, eventDetails) => {
|
||||
return this.remove(measurementUID, source, eventDetails);
|
||||
};
|
||||
source.getAnnotation = (definition, measurementId) => {
|
||||
return this.getAnnotation(source, definition, measurementId);
|
||||
|
||||
source.getAnnotation = (annotationType, measurementId) => {
|
||||
return this.getAnnotation(source, annotationType, measurementId);
|
||||
};
|
||||
|
||||
log.info(`New '${name}@${version}' source added.`);
|
||||
this.sources[id] = source;
|
||||
this.sources[uid] = source;
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
getSource(name, version) {
|
||||
const { sources } = this;
|
||||
const id = this._getSourceId(name, version);
|
||||
const uid = this._getSourceUID(name, version);
|
||||
|
||||
return sources[id];
|
||||
return sources[uid];
|
||||
}
|
||||
|
||||
getSourceMappings(name, version) {
|
||||
const { mappings } = this;
|
||||
const id = this._getSourceId(name, version);
|
||||
const uid = this._getSourceUID(name, version);
|
||||
|
||||
return mappings[id];
|
||||
}
|
||||
|
||||
_getSourceId(name, version) {
|
||||
const { sources } = this;
|
||||
|
||||
const sourceId = Object.keys(sources).find(sourceId => {
|
||||
const source = sources[sourceId];
|
||||
|
||||
return source.name === name && source.version === version;
|
||||
});
|
||||
|
||||
return sourceId;
|
||||
return mappings[uid];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new measurement matching criteria along with mapping functions.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition Definition of the measurement (Annotation Type)
|
||||
* @param {string} annotationType annotation type to match which can be e.g., Length, Bidirectional, etc.
|
||||
* @param {MatchingCriteria} matchingCriteria The matching criteria
|
||||
* @param {Function} toSourceSchema Mapping function to source schema
|
||||
* @param {Function} toAnnotationSchema Mapping function to annotation schema
|
||||
* @param {Function} toMeasurementSchema Mapping function to measurement schema
|
||||
* @return void
|
||||
*/
|
||||
addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toAnnotationSchema,
|
||||
toMeasurementSchema
|
||||
) {
|
||||
if (!this._isValidSource(source)) {
|
||||
@@ -211,11 +274,11 @@ class MeasurementService {
|
||||
throw new Error('Matching criteria not provided.');
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
throw new Error('Definition not provided.');
|
||||
if (!annotationType) {
|
||||
throw new Error('annotationType not provided.');
|
||||
}
|
||||
|
||||
if (!toSourceSchema) {
|
||||
if (!toAnnotationSchema) {
|
||||
throw new Error('Mapping function to source schema not provided.');
|
||||
}
|
||||
|
||||
@@ -225,19 +288,19 @@ class MeasurementService {
|
||||
|
||||
const mapping = {
|
||||
matchingCriteria,
|
||||
definition,
|
||||
toSourceSchema,
|
||||
annotationType,
|
||||
toAnnotationSchema,
|
||||
toMeasurementSchema,
|
||||
};
|
||||
|
||||
if (Array.isArray(this.mappings[source.id])) {
|
||||
this.mappings[source.id].push(mapping);
|
||||
if (Array.isArray(this.mappings[source.uid])) {
|
||||
this.mappings[source.uid].push(mapping);
|
||||
} else {
|
||||
this.mappings[source.id] = [mapping];
|
||||
this.mappings[source.uid] = [mapping];
|
||||
}
|
||||
|
||||
log.info(
|
||||
`New measurement mapping added to source '${this._getSourceInfo(
|
||||
`New measurement mapping added to source '${this._getSourceToString(
|
||||
source
|
||||
)}'.`
|
||||
);
|
||||
@@ -247,81 +310,82 @@ class MeasurementService {
|
||||
* Get annotation for specific source.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {string} measurementId The measurement service measurement id
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {string} measurementUID The measurement service measurement uid
|
||||
* @return {Object} Source measurement schema
|
||||
*/
|
||||
getAnnotation(source, definition, measurementId) {
|
||||
getAnnotation(source, annotationType, measurementUID) {
|
||||
if (!this._isValidSource(source)) {
|
||||
log.warn('Invalid source. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
log.warn('No source definition provided. Exiting early.');
|
||||
if (!annotationType) {
|
||||
log.warn('No source annotationType provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
const measurement = this.getMeasurement(measurementUID);
|
||||
const mapping = this._getMappingByMeasurementSource(
|
||||
measurementId,
|
||||
definition
|
||||
measurement,
|
||||
annotationType
|
||||
);
|
||||
const measurement = this.getMeasurement(measurementId);
|
||||
if (mapping) return mapping.toSourceSchema(measurement, definition);
|
||||
|
||||
if (mapping) {
|
||||
return mapping.toAnnotationSchema(measurement, annotationType);
|
||||
}
|
||||
|
||||
const matchingMapping = this._getMatchingMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
measurement
|
||||
);
|
||||
|
||||
if (matchingMapping) {
|
||||
log.info('Matching mapping found:', matchingMapping);
|
||||
const { toSourceSchema, definition } = matchingMapping;
|
||||
return toSourceSchema(measurement, definition);
|
||||
const { toAnnotationSchema, annotationType } = matchingMapping;
|
||||
return toAnnotationSchema(measurement, annotationType);
|
||||
}
|
||||
}
|
||||
|
||||
update(id, measurement, notYetUpdatedAtSource = false) {
|
||||
if (this.measurements[id]) {
|
||||
const updatedMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
log.info(
|
||||
`Updating internal measurement representation...`,
|
||||
updatedMeasurement
|
||||
);
|
||||
|
||||
this.measurements[id] = updatedMeasurement;
|
||||
|
||||
this._broadcastEvent(
|
||||
// Add an internal flag to say the measurement has not yet been updated at source.
|
||||
this.EVENTS.MEASUREMENT_UPDATED,
|
||||
{
|
||||
source: measurement.source,
|
||||
measurement: updatedMeasurement,
|
||||
notYetUpdatedAtSource,
|
||||
}
|
||||
);
|
||||
|
||||
return updatedMeasurement.id;
|
||||
update(measurementUID, measurement, notYetUpdatedAtSource = false) {
|
||||
if (!this.measurements[measurementUID]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
log.info(
|
||||
`Updating internal measurement representation...`,
|
||||
updatedMeasurement
|
||||
);
|
||||
|
||||
this.measurements[measurementUID] = updatedMeasurement;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
measurement: updatedMeasurement,
|
||||
notYetUpdatedAtSource,
|
||||
});
|
||||
|
||||
return updatedMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a raw measurement into a source so that it may be
|
||||
* Converted to/from annotation in the same way. E.g. import serialized data
|
||||
* Of the same form as the measurement source.
|
||||
* of the same form as the measurement source.
|
||||
* @param {MeasurementSource} source The measurement source instance.
|
||||
* @param {string} definition The source definition you want to add the measurement to.
|
||||
* @param {string} annotationType The source annotationType you want to add the measurement to.
|
||||
* @param {object} data The data you wish to add to the source.
|
||||
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source definition.
|
||||
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source annotationType.
|
||||
*/
|
||||
addRawMeasurement(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
data,
|
||||
toMeasurementSchema,
|
||||
dataSource = {}
|
||||
@@ -331,10 +395,10 @@ class MeasurementService {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceInfo = this._getSourceInfo(source);
|
||||
const sourceInfo = this._getSourceToString(source);
|
||||
|
||||
if (!definition) {
|
||||
log.warn('No source definition provided. Exiting early.');
|
||||
if (!annotationType) {
|
||||
log.warn('No source annotationType provided. Exiting early.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -347,13 +411,11 @@ class MeasurementService {
|
||||
|
||||
let measurement = {};
|
||||
try {
|
||||
/* Convert measurement */
|
||||
measurement = toMeasurementSchema(data);
|
||||
/* Assign measurement source instance */
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Failed to map '${sourceInfo}' measurement for definition ${definition}:`,
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
|
||||
error.message
|
||||
);
|
||||
return;
|
||||
@@ -366,31 +428,27 @@ class MeasurementService {
|
||||
return;
|
||||
}
|
||||
|
||||
let internalId = data.id;
|
||||
if (!internalId) {
|
||||
internalId = guid();
|
||||
log.warn(`Measurement ID not found. Generating UID: ${internalId}`);
|
||||
let internalUID = data.id;
|
||||
if (!internalUID) {
|
||||
internalUID = guid();
|
||||
log.warn(`Measurement ID not found. Generating UID: ${internalUID}`);
|
||||
}
|
||||
|
||||
const newMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
id: internalId,
|
||||
uid: internalUID,
|
||||
};
|
||||
|
||||
if (this.measurements[internalId]) {
|
||||
log.info(
|
||||
`Measurement already defined. Updating measurement.`,
|
||||
newMeasurement
|
||||
);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
if (this.measurements[internalUID]) {
|
||||
this.measurements[internalUID] = newMeasurement;
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
});
|
||||
} else {
|
||||
log.info(`Measurement added.`, newMeasurement);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
this.measurements[internalUID] = newMeasurement;
|
||||
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
@@ -406,20 +464,20 @@ class MeasurementService {
|
||||
* Adds or update persisted measurements.
|
||||
*
|
||||
* @param {MeasurementSource} source The measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {Measurement} measurement The source measurement
|
||||
* @return {string} A measurement id
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {EventDetail} sourceAnnotationDetail for the annotation event
|
||||
* @return {string} A measurement uid
|
||||
*/
|
||||
addOrUpdate(source, definition, sourceMeasurement) {
|
||||
annotationToMeasurement(source, annotationType, sourceAnnotationDetail) {
|
||||
if (!this._isValidSource(source)) {
|
||||
throw new Error('Invalid source.');
|
||||
}
|
||||
|
||||
if (!definition) {
|
||||
throw new Error('No source definition provided.');
|
||||
if (!annotationType) {
|
||||
throw new Error('No source annotationType provided.');
|
||||
}
|
||||
|
||||
const sourceInfo = this._getSourceInfo(source);
|
||||
const sourceInfo = this._getSourceToString(source);
|
||||
|
||||
if (!this._sourceHasMappings(source)) {
|
||||
throw new Error(
|
||||
@@ -429,19 +487,17 @@ class MeasurementService {
|
||||
|
||||
let measurement = {};
|
||||
try {
|
||||
const sourceMappings = this.mappings[source.id];
|
||||
const sourceMappings = this.mappings[source.uid];
|
||||
const { toMeasurementSchema } = sourceMappings.find(
|
||||
mapping => mapping.definition === definition
|
||||
mapping => mapping.annotationType === annotationType
|
||||
);
|
||||
|
||||
/* Convert measurement */
|
||||
measurement = toMeasurementSchema(sourceMeasurement);
|
||||
|
||||
/* Assign measurement source instance */
|
||||
measurement = toMeasurementSchema(sourceAnnotationDetail);
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to map '${sourceInfo}' measurement for definition ${definition}:`,
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
|
||||
error.message
|
||||
);
|
||||
}
|
||||
@@ -452,24 +508,23 @@ class MeasurementService {
|
||||
);
|
||||
}
|
||||
|
||||
let internalId = sourceMeasurement.id;
|
||||
if (!internalId) {
|
||||
internalId = guid();
|
||||
log.info(`Measurement ID not found. Generating UID: ${internalId}`);
|
||||
// Todo: we are using uid on the eventDetail, it should be uid of annotation
|
||||
let internalUID = sourceAnnotationDetail.uid;
|
||||
if (!internalUID) {
|
||||
internalUID = guid();
|
||||
log.info(
|
||||
`Annotation does not have UID, Generating UID for the created Measurement: ${internalUID}`
|
||||
);
|
||||
}
|
||||
|
||||
const newMeasurement = {
|
||||
...measurement,
|
||||
modifiedTimestamp: Math.floor(Date.now() / 1000),
|
||||
id: internalId,
|
||||
uid: internalUID,
|
||||
};
|
||||
|
||||
if (this.measurements[internalId]) {
|
||||
log.info(
|
||||
`Measurement already defined. Updating measurement.`,
|
||||
newMeasurement
|
||||
);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
if (this.measurements[internalUID]) {
|
||||
this.measurements[internalUID] = newMeasurement;
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
@@ -477,51 +532,52 @@ class MeasurementService {
|
||||
});
|
||||
} else {
|
||||
log.info('Measurement added.', newMeasurement);
|
||||
this.measurements[internalId] = newMeasurement;
|
||||
this.measurements[internalUID] = newMeasurement;
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
|
||||
source,
|
||||
measurement: newMeasurement,
|
||||
});
|
||||
}
|
||||
|
||||
return newMeasurement.id;
|
||||
return newMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a measurement and broadcasts the removed event.
|
||||
*
|
||||
* @param {string} id The measurement id
|
||||
* @param {string} measurementUID The measurement uid
|
||||
* @param {MeasurementSource} source The measurement source instance
|
||||
* @return {string} The removed measurement id
|
||||
*/
|
||||
remove(id, source) {
|
||||
if (!id || !this.measurements[id]) {
|
||||
log.warn(`No id provided, or unable to find measurement by id.`);
|
||||
remove(measurementUID, source, eventDetails) {
|
||||
if (!measurementUID || !this.measurements[measurementUID]) {
|
||||
log.warn(`No uid provided, or unable to find measurement by uid.`);
|
||||
return;
|
||||
}
|
||||
|
||||
delete this.measurements[id];
|
||||
delete this.measurements[measurementUID];
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, {
|
||||
source,
|
||||
measurement: id, // This is weird :shrug:
|
||||
measurement: measurementUID,
|
||||
...eventDetails,
|
||||
});
|
||||
}
|
||||
|
||||
clearMeasurements() {
|
||||
// Make a copy of the measurements
|
||||
const measurements = { ...this.measurements };
|
||||
this.measurements = {};
|
||||
this._jumpToMeasurementCache = {};
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED);
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
|
||||
}
|
||||
|
||||
jumpToMeasurement(viewportIndex, id) {
|
||||
const measurement = this.measurements[id];
|
||||
jumpToMeasurement(viewportIndex, measurementUID) {
|
||||
const measurement = this.measurements[measurementUID];
|
||||
|
||||
if (!measurement) {
|
||||
log.warn(`No id provided, or unable to find measurement by id.`);
|
||||
log.warn(`No measurement uid, or unable to find by uid.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._addJumpToMeasurement(viewportIndex, id);
|
||||
this._addJumpToMeasurement(viewportIndex, measurementUID);
|
||||
|
||||
const eventName = this.EVENTS.JUMP_TO_MEASUREMENT;
|
||||
|
||||
@@ -535,10 +591,6 @@ class MeasurementService {
|
||||
}
|
||||
}
|
||||
|
||||
_addJumpToMeasurement(viewportIndex, id) {
|
||||
this._jumpToMeasurementCache[viewportIndex] = id;
|
||||
}
|
||||
|
||||
getJumpToMeasurement(viewportIndex) {
|
||||
return this._jumpToMeasurementCache[viewportIndex];
|
||||
}
|
||||
@@ -547,36 +599,43 @@ class MeasurementService {
|
||||
delete this._jumpToMeasurementCache[viewportIndex];
|
||||
}
|
||||
|
||||
_getMappingByMeasurementSource(measurementId, definition) {
|
||||
const measurement = this.getMeasurement(measurementId);
|
||||
if (this._isValidSource(measurement.source)) {
|
||||
return this.mappings[measurement.source.id].find(
|
||||
m => m.definition === definition
|
||||
);
|
||||
}
|
||||
_getSourceUID(name, version) {
|
||||
const { sources } = this;
|
||||
|
||||
const sourceUID = Object.keys(sources).find(sourceUID => {
|
||||
const source = sources[sourceUID];
|
||||
|
||||
return source.name === name && source.version === version;
|
||||
});
|
||||
|
||||
return sourceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all measurements and broadcasts cleared event.
|
||||
*/
|
||||
clear() {
|
||||
this.measurements = {};
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED);
|
||||
_addJumpToMeasurement(viewportIndex, measurementUID) {
|
||||
this._jumpToMeasurementCache[viewportIndex] = measurementUID;
|
||||
}
|
||||
|
||||
_getMappingByMeasurementSource(measurement, annotationType) {
|
||||
if (this._isValidSource(measurement.source)) {
|
||||
return this.mappings[measurement.source.uid].find(
|
||||
m => m.annotationType === annotationType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get measurement mapping function if matching criteria.
|
||||
*
|
||||
* @param {MeasurementSource} source Measurement source instance
|
||||
* @param {string} definition The source definition
|
||||
* @param {string} annotationType The source annotationType
|
||||
* @param {Measurement} measurement The measurement service measurement
|
||||
* @return {Object} The mapping based on matched criteria
|
||||
*/
|
||||
_getMatchingMapping(source, definition, measurement) {
|
||||
const sourceMappings = this.mappings[source.id];
|
||||
_getMatchingMapping(source, annotationType, measurement) {
|
||||
const sourceMappings = this.mappings[source.uid];
|
||||
|
||||
const sourceMappingsByDefinition = sourceMappings.filter(
|
||||
mapping => mapping.definition === definition
|
||||
mapping => mapping.annotationType === annotationType
|
||||
);
|
||||
|
||||
/* Criteria Matching */
|
||||
@@ -594,7 +653,7 @@ class MeasurementService {
|
||||
* @param {MeasurementSource} source Measurement source
|
||||
* @return {string} Source information
|
||||
*/
|
||||
_getSourceInfo(source) {
|
||||
_getSourceToString(source) {
|
||||
return `${source.name}@${source.version}`;
|
||||
}
|
||||
|
||||
@@ -605,7 +664,7 @@ class MeasurementService {
|
||||
* @return {boolean} Measurement source validation
|
||||
*/
|
||||
_isValidSource(source) {
|
||||
return source && this.sources[source.id];
|
||||
return source && this.sources[source.uid];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,7 +675,8 @@ class MeasurementService {
|
||||
*/
|
||||
_sourceHasMappings(source) {
|
||||
return (
|
||||
Array.isArray(this.mappings[source.id]) && this.mappings[source.id].length
|
||||
Array.isArray(this.mappings[source.uid]) &&
|
||||
this.mappings[source.uid].length
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('MeasurementService.js', () => {
|
||||
let measurementService;
|
||||
let measurement;
|
||||
let source;
|
||||
let definition;
|
||||
let annotationType;
|
||||
let matchingCriteria;
|
||||
let toSourceSchema;
|
||||
let toMeasurement;
|
||||
@@ -20,9 +20,9 @@ describe('MeasurementService.js', () => {
|
||||
beforeEach(() => {
|
||||
measurementService = new MeasurementService();
|
||||
source = measurementService.createSource('Test', '1');
|
||||
definition = 'Length';
|
||||
annotationType = 'Length';
|
||||
annotation = {
|
||||
toolName: definition,
|
||||
toolName: annotationType,
|
||||
measurementData: {},
|
||||
};
|
||||
measurement = {
|
||||
@@ -47,7 +47,7 @@ describe('MeasurementService.js', () => {
|
||||
}
|
||||
|
||||
return measurement;
|
||||
}
|
||||
};
|
||||
matchingCriteria = {
|
||||
valueType: measurementService.VALUE_TYPES.POLYLINE,
|
||||
points: 2,
|
||||
@@ -63,13 +63,13 @@ describe('MeasurementService.js', () => {
|
||||
|
||||
it('throws Error if no name provided', () => {
|
||||
expect(() => {
|
||||
measurementService.createSource(null, '1')
|
||||
measurementService.createSource(null, '1');
|
||||
}).toThrow(new Error('Source name not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no version provided', () => {
|
||||
expect(() => {
|
||||
measurementService.createSource('Testing', null)
|
||||
measurementService.createSource('Testing', null);
|
||||
}).toThrow(new Error('Source version not provided.'));
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ describe('MeasurementService.js', () => {
|
||||
it('adds new mapping', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -91,7 +91,7 @@ describe('MeasurementService.js', () => {
|
||||
|
||||
measurementService.addMapping(
|
||||
invalidSource,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -103,7 +103,7 @@ describe('MeasurementService.js', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
null,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -111,12 +111,11 @@ describe('MeasurementService.js', () => {
|
||||
}).toThrow(new Error('Matching criteria not provided.'));
|
||||
});
|
||||
|
||||
|
||||
it('throws Error if no source provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
null /* source */,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -124,23 +123,23 @@ describe('MeasurementService.js', () => {
|
||||
}).toThrow(new Error('Invalid source.'));
|
||||
});
|
||||
|
||||
it('logs warning and return early if no definition provided', () => {
|
||||
it('logs warning and return early if no AnnotationType provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
null /* definition */,
|
||||
null /* AnnotationType */,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
}).toThrow(new Error('Definition not provided.'));
|
||||
}).toThrow(new Error('annotationType not provided.'));
|
||||
});
|
||||
|
||||
it('throws Error if no measurement mapping function provided', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
null /* toSourceSchema */,
|
||||
toMeasurement
|
||||
@@ -152,7 +151,7 @@ describe('MeasurementService.js', () => {
|
||||
expect(() => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
null /* toMeasurement */
|
||||
@@ -165,27 +164,39 @@ describe('MeasurementService.js', () => {
|
||||
it('get annotation based on matched criteria', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
const measurementId = source.addOrUpdate(definition, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(definition, measurementId);
|
||||
const measurementId = source.annotationToMeasurement(
|
||||
annotationType,
|
||||
annotation
|
||||
);
|
||||
const mappedAnnotation = source.getAnnotation(
|
||||
annotationType,
|
||||
measurementId
|
||||
);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
|
||||
it('get annotation based on source and definition', () => {
|
||||
it('get annotation based on source and annotationType', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
{},
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
const measurementId = source.addOrUpdate(definition, annotation);
|
||||
const mappedAnnotation = source.getAnnotation(definition, measurementId);
|
||||
const measurementId = source.annotationToMeasurement(
|
||||
annotationType,
|
||||
annotation
|
||||
);
|
||||
const mappedAnnotation = source.getAnnotation(
|
||||
annotationType,
|
||||
measurementId
|
||||
);
|
||||
|
||||
expect(annotation).toBe(mappedAnnotation);
|
||||
});
|
||||
@@ -201,14 +212,14 @@ describe('MeasurementService.js', () => {
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.addOrUpdate(definition, anotherMeasurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, anotherMeasurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
@@ -220,34 +231,34 @@ describe('MeasurementService.js', () => {
|
||||
it('return measurement service measurement with given id', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
const returnedMeasurement = measurementService.getMeasurement(id);
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
const returnedMeasurement = measurementService.getMeasurement(uid);
|
||||
|
||||
/* Clear dynamic data */
|
||||
delete returnedMeasurement.modifiedTimestamp;
|
||||
|
||||
expect({ id, ...measurement }).toEqual(returnedMeasurement);
|
||||
expect({ uid, ...measurement }).toEqual(returnedMeasurement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOrUpdate()', () => {
|
||||
describe('annotationToMeasurement()', () => {
|
||||
it('adds new measurements', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
@@ -256,39 +267,39 @@ describe('MeasurementService.js', () => {
|
||||
|
||||
it('fails to add new measurements when no mapping', () => {
|
||||
expect(() => {
|
||||
source.addOrUpdate(definition, measurement);
|
||||
}).toThrow()
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('fails to add new measurements when invalid mapping function', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
1 /* Invalid */
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
source.addOrUpdate(definition, measurement);
|
||||
}).toThrow()
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('adds new measurement with custom id', () => {
|
||||
const newMeasurement = { id: 1, ...measurement };
|
||||
it('adds new measurement with custom uid', () => {
|
||||
const newMeasurement = { uid: 1, ...measurement };
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
/* Add new measurement */
|
||||
source.addOrUpdate(definition, newMeasurement);
|
||||
source.annotationToMeasurement(annotationType, newMeasurement);
|
||||
const savedMeasurement = measurementService.getMeasurement(
|
||||
newMeasurement.id
|
||||
newMeasurement.uid
|
||||
);
|
||||
|
||||
/* Clear dynamic data */
|
||||
@@ -303,32 +314,32 @@ describe('MeasurementService.js', () => {
|
||||
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
source.addOrUpdate(definition, measurement);
|
||||
}).toThrow()
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('updates existing measurement', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
);
|
||||
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
measurement.unit = 'HU';
|
||||
|
||||
source.addOrUpdate(definition, { id, ...measurement });
|
||||
const updatedMeasurement = measurementService.getMeasurement(id);
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement });
|
||||
const updatedMeasurement = measurementService.getMeasurement(uid);
|
||||
|
||||
expect(updatedMeasurement.unit).toBe('HU');
|
||||
});
|
||||
@@ -338,7 +349,7 @@ describe('MeasurementService.js', () => {
|
||||
it('subscribers receive broadcasted add event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -354,7 +365,7 @@ describe('MeasurementService.js', () => {
|
||||
);
|
||||
|
||||
/* Add new measurement */
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
expect(addCallbackWasCalled).toBe(true);
|
||||
});
|
||||
@@ -362,7 +373,7 @@ describe('MeasurementService.js', () => {
|
||||
it('subscribers receive broadcasted update event', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -378,10 +389,10 @@ describe('MeasurementService.js', () => {
|
||||
);
|
||||
|
||||
/* Create measurement */
|
||||
const id = source.addOrUpdate(definition, measurement);
|
||||
const uid = source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
/* Update measurement */
|
||||
source.addOrUpdate(definition, { id, ...measurement });
|
||||
source.annotationToMeasurement(annotationType, { uid, ...measurement });
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(true);
|
||||
});
|
||||
@@ -389,7 +400,7 @@ describe('MeasurementService.js', () => {
|
||||
it('unsubscribes a listener', () => {
|
||||
measurementService.addMapping(
|
||||
source,
|
||||
definition,
|
||||
annotationType,
|
||||
matchingCriteria,
|
||||
toSourceSchema,
|
||||
toMeasurement
|
||||
@@ -408,7 +419,7 @@ describe('MeasurementService.js', () => {
|
||||
unsubscribe();
|
||||
|
||||
/* Create measurement */
|
||||
source.addOrUpdate(definition, measurement);
|
||||
source.annotationToMeasurement(annotationType, measurement);
|
||||
|
||||
expect(updateCallbackWasCalled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
|
||||
|
||||
const EVENTS = {
|
||||
SEGMENTATION_UPDATED: 'event::segmentation_updated',
|
||||
SEGMENTATION_ADDED: 'event::segmentation_added',
|
||||
SEGMENTATION_REMOVED: 'event::segmentation_removed',
|
||||
SEGMENTATION_VISIBILITY_CHANGED: 'event::SEGMENTATION_VISIBILITY_CHANGED',
|
||||
};
|
||||
|
||||
const VALUE_TYPES = {};
|
||||
|
||||
class SegmentationService {
|
||||
constructor() {
|
||||
this.segmentations = {};
|
||||
this.listeners = {};
|
||||
Object.defineProperty(this, 'EVENTS', {
|
||||
value: EVENTS,
|
||||
writable: false,
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
});
|
||||
|
||||
Object.assign(this, pubSubServiceInterface);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all segmentations.
|
||||
*
|
||||
* @return Array of segmentations
|
||||
*/
|
||||
getSegmentations() {
|
||||
const segmentations = this._arrayOfObjects(this.segmentations);
|
||||
return (
|
||||
segmentations &&
|
||||
segmentations.map(m => this.segmentations[Object.keys(m)[0]])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific segmentation by its id.
|
||||
*
|
||||
* @param id If of the segmentation
|
||||
* @return segmentation instance
|
||||
*/
|
||||
getSegmentation(id) {
|
||||
return this.segmentations[id];
|
||||
}
|
||||
|
||||
addOrUpdateSegmentation(
|
||||
id,
|
||||
segmentationSchema,
|
||||
notYetUpdatedAtSource = false
|
||||
) {
|
||||
const segmentation = this.segmentations[id];
|
||||
|
||||
if (segmentation) {
|
||||
Object.assign(segmentation, segmentationSchema);
|
||||
|
||||
this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, {
|
||||
id,
|
||||
segmentation,
|
||||
notYetUpdatedAtSource: notYetUpdatedAtSource,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.segmentations[id] = {
|
||||
...segmentationSchema,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
this._broadcastEvent(this.EVENTS.SEGMENTATION_ADDED, {
|
||||
id,
|
||||
segmentation,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of a segmentation in the state, and broadcasts the event.
|
||||
* Note: this method does not update the segmentation state in the source. It only
|
||||
* updates the state, and there should be separate listeners for that.
|
||||
* @param ids segmentation ids
|
||||
*/
|
||||
toggleSegmentationsVisibility(ids) {
|
||||
ids.forEach(id => {
|
||||
const segmentation = this.segmentations[id];
|
||||
if (!segmentation) {
|
||||
throw new Error(`Segmentation with id ${id} not found.`);
|
||||
}
|
||||
segmentation.visible = !segmentation.visible;
|
||||
this._broadcastEvent(this.EVENTS.SEGMENTATION_VISIBILITY_CHANGED, {
|
||||
segmentation,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a segmentation and broadcasts the removed event.
|
||||
*
|
||||
* @param {string} id The segmentation id
|
||||
*/
|
||||
remove(id) {
|
||||
if (!id || !this.segmentations[id]) {
|
||||
console.warn(`No id provided, or unable to find segmentation by id.`);
|
||||
return;
|
||||
}
|
||||
delete this.segmentations[id];
|
||||
this._broadcastEvent(this.EVENTS.SEGMENTATION_REMOVED, {
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all segmentations and broadcasts cleared event.
|
||||
*/
|
||||
clear() {
|
||||
Object.keys(this.segmentations).forEach(id => {
|
||||
this.remove(id);
|
||||
});
|
||||
|
||||
this.segmentations = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts object of objects to array.
|
||||
*
|
||||
* @return {Array} Array of objects
|
||||
*/
|
||||
_arrayOfObjects = obj => {
|
||||
return Object.entries(obj).map(e => ({ [e[0]]: e[1] }));
|
||||
};
|
||||
}
|
||||
|
||||
export default SegmentationService;
|
||||
export { EVENTS, VALUE_TYPES };
|
||||
@@ -0,0 +1,8 @@
|
||||
import SegmentationService from './SegmentationService';
|
||||
|
||||
export default {
|
||||
name: 'SegmentationService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new SegmentationService();
|
||||
},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export default class ToolBarService {
|
||||
// TODO: Do we need to track per context? Or do we allow for a mixed
|
||||
// definition that adapts based on context?
|
||||
this.state = {
|
||||
primaryToolId: 'Wwwc',
|
||||
primaryToolId: 'WindowLevel',
|
||||
toggles: {
|
||||
/* id: true/false */
|
||||
},
|
||||
@@ -43,11 +43,13 @@ export default class ToolBarService {
|
||||
reset() {
|
||||
this.unsubscriptions.forEach(unsub => unsub());
|
||||
this.state = {
|
||||
primaryToolId: 'Wwwc',
|
||||
primaryToolId: 'WindowLevel',
|
||||
toggles: {},
|
||||
groups: {},
|
||||
};
|
||||
this.unsubscriptions = [];
|
||||
this.buttonSections = {};
|
||||
this.buttons = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,17 +58,24 @@ export default class ToolBarService {
|
||||
*/
|
||||
recordInteraction(interaction) {
|
||||
const commandsManager = this._commandsManager;
|
||||
const { groupId, itemId, interactionType, commandName, commandOptions } = interaction;
|
||||
const { groupId, itemId, interactionType, commands } = interaction;
|
||||
|
||||
switch (interactionType) {
|
||||
case 'action': {
|
||||
commands.forEach(({ commandName, commandOptions, context }) => {
|
||||
if (commandName) {
|
||||
commandsManager.runCommand(commandName, commandOptions, context);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'tool': {
|
||||
this.state.primaryToolId = itemId;
|
||||
// TODO: Force run this for all contexts? Even inactive?
|
||||
// or... They'll just detect primaryToolId when they spin up and apply...
|
||||
commandsManager.runCommand('setToolActive', commandOptions);
|
||||
commands.forEach(
|
||||
({ commandName = 'setToolActive', commandOptions, context }) => {
|
||||
commandsManager.runCommand(commandName, commandOptions, context);
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'toggle': {
|
||||
@@ -74,30 +83,41 @@ export default class ToolBarService {
|
||||
this.state.toggles[itemId] === undefined
|
||||
? true
|
||||
: !this.state.toggles[itemId];
|
||||
if (commandOptions) {
|
||||
commandOptions.toggledState = this.state.toggles[itemId];
|
||||
}
|
||||
|
||||
const { commands } = interaction;
|
||||
|
||||
commands.forEach(({ commandName, commandOptions, context }) => {
|
||||
if (!commandOptions) {
|
||||
commandOptions = {};
|
||||
}
|
||||
|
||||
if (commandName) {
|
||||
commandOptions.toggledState = this.state.toggles[itemId];
|
||||
commandsManager.runCommand(commandName, commandOptions, context);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid interaction type: ${interactionType}`);
|
||||
}
|
||||
|
||||
// Todo: comment out for now
|
||||
// Run command if there's one associated
|
||||
//
|
||||
// NOTE: Should probably just do this for tools as well?
|
||||
// But would be nice if we could enforce at least the command name?
|
||||
let unsubscribe;
|
||||
if (commandName) {
|
||||
unsubscribe = commandsManager.runCommand(commandName, commandOptions);
|
||||
}
|
||||
// let unsubscribe;
|
||||
// if (commandName) {
|
||||
// unsubscribe = commandsManager.runCommand(commandName, commandOptions);
|
||||
// }
|
||||
|
||||
// Storing the unsubscribe for later reseting
|
||||
if (unsubscribe && typeof unsubscribe === 'function') {
|
||||
if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
|
||||
this.unsubscriptions.push(unsubscribe);
|
||||
}
|
||||
}
|
||||
// // Storing the unsubscribe for later reseting
|
||||
// if (unsubscribe && typeof unsubscribe === 'function') {
|
||||
// if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
|
||||
// this.unsubscriptions.push(unsubscribe);
|
||||
// }
|
||||
// }
|
||||
|
||||
// Track last touched id for each group
|
||||
if (groupId) {
|
||||
|
||||
@@ -4,7 +4,7 @@ const publicAPI = {
|
||||
name,
|
||||
getState: _getState,
|
||||
setActiveViewportIndex: _setActiveViewportIndex,
|
||||
setDisplaysetForViewport: _setDisplaysetForViewport,
|
||||
setDisplaySetsForViewport: _setDisplaySetsForViewport,
|
||||
setLayout: _setLayout,
|
||||
setCachedLayout: _setCachedLayout,
|
||||
setServiceImplementation,
|
||||
@@ -16,8 +16,8 @@ const serviceImplementation = {
|
||||
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
|
||||
_setActiveViewportIndex: () =>
|
||||
console.warn('setActiveViewportIndex() NOT IMPLEMENTED'),
|
||||
_setDisplaysetForViewport: () =>
|
||||
console.warn('setDisplaysetForViewport() NOT IMPLEMENTED'),
|
||||
_setDisplaySetsForViewport: () =>
|
||||
console.warn('setDisplaySetsForViewport() NOT IMPLEMENTED'),
|
||||
_setLayout: () => console.warn('setLayout() NOT IMPLEMENTED'),
|
||||
_reset: () => console.warn('reset() NOT IMPLEMENTED'),
|
||||
_setCachedLayout: () => console.warn('setCachedLayout() NOT IMPLEMENTED'),
|
||||
@@ -32,10 +32,10 @@ function _setActiveViewportIndex(index) {
|
||||
return serviceImplementation._setActiveViewportIndex(index);
|
||||
}
|
||||
|
||||
function _setDisplaysetForViewport({ viewportIndex, displaySetInstanceUID }) {
|
||||
return serviceImplementation._setDisplaysetForViewport({
|
||||
function _setDisplaySetsForViewport({ viewportIndex, displaySetInstanceUIDs }) {
|
||||
return serviceImplementation._setDisplaySetsForViewport({
|
||||
viewportIndex,
|
||||
displaySetInstanceUID,
|
||||
displaySetInstanceUIDs,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ function _setCachedLayout({ numCols, numRows, viewports }) {
|
||||
function setServiceImplementation({
|
||||
getState: getStateImplementation,
|
||||
setActiveViewportIndex: setActiveViewportIndexImplementation,
|
||||
setDisplaysetForViewport: setDisplaysetForViewportImplementation,
|
||||
setDisplaySetsForViewport: setDisplaySetsForViewportImplementation,
|
||||
setCachedLayout: setCachedLayoutImplementation,
|
||||
setLayout: setLayoutImplementation,
|
||||
reset: resetImplementation,
|
||||
@@ -70,8 +70,8 @@ function setServiceImplementation({
|
||||
if (setActiveViewportIndexImplementation) {
|
||||
serviceImplementation._setActiveViewportIndex = setActiveViewportIndexImplementation;
|
||||
}
|
||||
if (setDisplaysetForViewportImplementation) {
|
||||
serviceImplementation._setDisplaysetForViewport = setDisplaysetForViewportImplementation;
|
||||
if (setDisplaySetsForViewportImplementation) {
|
||||
serviceImplementation._setDisplaySetsForViewport = setDisplaySetsForViewportImplementation;
|
||||
}
|
||||
if (setLayoutImplementation) {
|
||||
serviceImplementation._setLayout = setLayoutImplementation;
|
||||
|
||||
@@ -12,6 +12,7 @@ import CineService from './CineService';
|
||||
import HangingProtocolService from './HangingProtocolService';
|
||||
import pubSubServiceInterface from './_shared/pubSubServiceInterface';
|
||||
import UserAuthenticationService from './UserAuthenticationService';
|
||||
import SegmentationService from './SegmentationService';
|
||||
|
||||
export {
|
||||
MeasurementService,
|
||||
@@ -28,4 +29,5 @@ export {
|
||||
CineService,
|
||||
pubSubServiceInterface,
|
||||
UserAuthenticationService,
|
||||
SegmentationService,
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
let stackMap = {};
|
||||
let configuration = {};
|
||||
const stackUpdatedCallbacks = [];
|
||||
|
||||
/**
|
||||
* Loop through the current series and add metadata to the
|
||||
* Cornerstone meta data provider. This will be used to fill information
|
||||
* into the viewport overlays, and to calculate reference lines and orientation markers
|
||||
* @param {Object} stackMap stackMap object
|
||||
* @param {Object} displaySet The set of images to make the stack from
|
||||
* @return {Array} Array with image IDs
|
||||
*/
|
||||
function createAndAddStack(
|
||||
stackMap,
|
||||
displaySet,
|
||||
dataSource,
|
||||
stackUpdatedCallbacks
|
||||
) {
|
||||
const {
|
||||
images,
|
||||
displaySetInstanceUID,
|
||||
StudyInstanceUID,
|
||||
frameRate,
|
||||
isClip,
|
||||
initialImageIdIndex,
|
||||
} = displaySet;
|
||||
if (!images) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
|
||||
const stack = {
|
||||
StudyInstanceUID,
|
||||
displaySetInstanceUID,
|
||||
imageIds,
|
||||
frameRate,
|
||||
isClip,
|
||||
initialImageIdIndex,
|
||||
};
|
||||
|
||||
stackMap[displaySetInstanceUID] = stack;
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
configuration = {
|
||||
createAndAddStack,
|
||||
};
|
||||
|
||||
/**
|
||||
* This object contains all the functions needed for interacting with the stack manager.
|
||||
* Generally, findStack is the only function used. If you want to know when new stacks
|
||||
* come in, you can register a callback with addStackUpdatedCallback.
|
||||
*/
|
||||
const StackManager = {
|
||||
/**
|
||||
* Removes all current stacks
|
||||
*/
|
||||
clearStacks() {
|
||||
stackMap = {};
|
||||
},
|
||||
/**
|
||||
* Create a stack from an image set, as well as add in the metadata on a per image bases.
|
||||
* @param displaySet The set of images to make the stack from
|
||||
* @return {Array} Array with image IDs
|
||||
*/
|
||||
makeAndAddStack(displaySet, dataSource) {
|
||||
return configuration.createAndAddStack(
|
||||
stackMap,
|
||||
displaySet,
|
||||
dataSource,
|
||||
stackUpdatedCallbacks
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Find a stack from the currently created stacks.
|
||||
* @param displaySetInstanceUID The UID of the stack to find.
|
||||
* @returns {*} undefined if not found, otherwise the stack object is returned.
|
||||
*/
|
||||
findStack(displaySetInstanceUID) {
|
||||
return stackMap[displaySetInstanceUID];
|
||||
},
|
||||
/**
|
||||
* Find a stack or create one if it has not been created yet
|
||||
* @param displaySet The set of images to make the stack from
|
||||
* @return {Array} Array with image IDs
|
||||
*/
|
||||
findOrCreateStack(displaySet, dataSource) {
|
||||
let stack = this.findStack(displaySet.displaySetInstanceUID);
|
||||
|
||||
if (!stack || !stack.imageIds) {
|
||||
stack = this.makeAndAddStack(displaySet, dataSource);
|
||||
}
|
||||
|
||||
return stack;
|
||||
},
|
||||
/**
|
||||
* Gets the underlying map of displaySetInstanceUID to stack object.
|
||||
* WARNING: Do not change this object. It directly affects the manager.
|
||||
* @returns {{}} map of displaySetInstanceUID -> stack.
|
||||
*/
|
||||
getAllStacks() {
|
||||
return stackMap;
|
||||
},
|
||||
/**
|
||||
* Adds in a callback to be called on a stack being added / updated.
|
||||
* @param callback must accept at minimum one argument,
|
||||
* which is the stack that was added / updated.
|
||||
*/
|
||||
addStackUpdatedCallback(callback) {
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('callback must be provided as a function');
|
||||
}
|
||||
stackUpdatedCallbacks.push(callback);
|
||||
},
|
||||
/**
|
||||
* Return configuration
|
||||
*/
|
||||
getConfiguration() {
|
||||
return configuration;
|
||||
},
|
||||
/**
|
||||
* Set configuration, in order to provide compatibility
|
||||
* with other systems by overriding this functions
|
||||
* @param {Object} config object with functions to be overrided
|
||||
*
|
||||
* For now, only makeAndAddStack can be overrided
|
||||
*/
|
||||
setConfiguration(config) {
|
||||
configuration = config;
|
||||
},
|
||||
};
|
||||
|
||||
export { StackManager };
|
||||
export default StackManager;
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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;
|
||||
@@ -1,205 +0,0 @@
|
||||
import cornerstone from 'cornerstone-core';
|
||||
import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
|
||||
import { api } from 'dicomweb-client';
|
||||
import DICOMWeb from '../DICOMWeb';
|
||||
|
||||
import errorHandler from '../errorHandler';
|
||||
|
||||
const getImageId = imageObj => {
|
||||
if (!imageObj) {
|
||||
return;
|
||||
}
|
||||
|
||||
return typeof imageObj.getImageId === 'function'
|
||||
? imageObj.getImageId()
|
||||
: imageObj.url;
|
||||
};
|
||||
|
||||
const findImageIdOnStudies = (studies, displaySetInstanceUID) => {
|
||||
const study = studies.find(study => {
|
||||
const displaySet = study.displaySets.some(
|
||||
displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUID
|
||||
);
|
||||
return displaySet;
|
||||
});
|
||||
const { series = [] } = study;
|
||||
const { instances = [] } = series[0] || {};
|
||||
const instance = instances[0];
|
||||
|
||||
return getImageId(instance);
|
||||
};
|
||||
|
||||
const someInvalidStrings = strings => {
|
||||
const stringsArray = Array.isArray(strings) ? strings : [strings];
|
||||
const emptyString = string => !string;
|
||||
let invalid = stringsArray.some(emptyString);
|
||||
return invalid;
|
||||
};
|
||||
|
||||
const getImageInstance = dataset => {
|
||||
return dataset && dataset.images && dataset.images[0];
|
||||
};
|
||||
|
||||
const getImageInstanceId = imageInstance => {
|
||||
return getImageId(imageInstance);
|
||||
};
|
||||
|
||||
const fetchIt = (url, headers = DICOMWeb.getAuthorizationHeader()) => {
|
||||
return fetch(url, headers).then(response => response.arrayBuffer());
|
||||
};
|
||||
|
||||
const cornerstoneRetriever = imageId => {
|
||||
return cornerstone.loadAndCacheImage(imageId).then(image => {
|
||||
return image && image.data && image.data.byteArray.buffer;
|
||||
});
|
||||
};
|
||||
|
||||
const wadorsRetriever = (
|
||||
url,
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
sopInstanceUID,
|
||||
headers = DICOMWeb.getAuthorizationHeader(),
|
||||
errorInterceptor = errorHandler.getHTTPErrorHandler()
|
||||
) => {
|
||||
const config = {
|
||||
url,
|
||||
headers,
|
||||
errorInterceptor,
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
|
||||
return dicomWeb.retrieveInstance({
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
sopInstanceUID,
|
||||
});
|
||||
};
|
||||
|
||||
const getImageLoaderType = imageId => {
|
||||
const loaderRegExp = /^\w+\:/;
|
||||
const loaderType = loaderRegExp.exec(imageId);
|
||||
|
||||
return (
|
||||
(loaderRegExp.lastIndex === 0 &&
|
||||
loaderType &&
|
||||
loaderType[0] &&
|
||||
loaderType[0].replace(':', '')) ||
|
||||
''
|
||||
);
|
||||
};
|
||||
|
||||
class DicomLoaderService {
|
||||
getLocalData(dataset, studies) {
|
||||
if (dataset && dataset.localFile) {
|
||||
// Use referenced imageInstance
|
||||
const imageInstance = getImageInstance(dataset);
|
||||
let imageId = getImageInstanceId(imageInstance);
|
||||
|
||||
// or Try to get it from studies
|
||||
if (someInvalidStrings(imageId)) {
|
||||
imageId = findImageIdOnStudies(studies, dataset.displaySetInstanceUID);
|
||||
}
|
||||
|
||||
if (!someInvalidStrings(imageId)) {
|
||||
return cornerstoneWADOImageLoader.wadouri.loadFileRequest(imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDataByImageType(dataset) {
|
||||
const imageInstance = getImageInstance(dataset);
|
||||
|
||||
if (imageInstance) {
|
||||
const imageId = getImageInstanceId(imageInstance);
|
||||
let getDicomDataMethod = fetchIt;
|
||||
const loaderType = getImageLoaderType(imageId);
|
||||
|
||||
switch (loaderType) {
|
||||
case 'dicomfile':
|
||||
getDicomDataMethod = cornerstoneRetriever.bind(this, imageId);
|
||||
break;
|
||||
case 'wadors':
|
||||
const url = imageInstance.getData().wadoRoot;
|
||||
const studyInstanceUID = imageInstance.getStudyInstanceUID();
|
||||
const seriesInstanceUID = imageInstance.getSeriesInstanceUID();
|
||||
const sopInstanceUID = imageInstance.getSOPInstanceUID();
|
||||
const invalidParams = someInvalidStrings([
|
||||
url,
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
sopInstanceUID,
|
||||
]);
|
||||
if (invalidParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
getDicomDataMethod = wadorsRetriever.bind(
|
||||
this,
|
||||
url,
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
sopInstanceUID
|
||||
);
|
||||
break;
|
||||
case 'wadouri':
|
||||
// Strip out the image loader specifier
|
||||
imageId = imageId.substring(imageId.indexOf(':') + 1);
|
||||
|
||||
if (someInvalidStrings(imageId)) {
|
||||
return;
|
||||
}
|
||||
getDicomDataMethod = fetchIt.bind(this, imageId);
|
||||
break;
|
||||
}
|
||||
|
||||
return getDicomDataMethod();
|
||||
}
|
||||
}
|
||||
|
||||
getDataByDatasetType(dataset) {
|
||||
const {
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
authorizationHeaders,
|
||||
wadoRoot,
|
||||
wadoUri,
|
||||
} = dataset;
|
||||
// Retrieve wadors or just try to fetch wadouri
|
||||
if (!someInvalidStrings(wadoRoot)) {
|
||||
return wadorsRetriever(
|
||||
wadoRoot,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
authorizationHeaders
|
||||
);
|
||||
} else if (!someInvalidStrings(wadoUri)) {
|
||||
return fetchIt(wadoUri, { headers: authorizationHeaders });
|
||||
}
|
||||
}
|
||||
|
||||
*getLoaderIterator(dataset, studies) {
|
||||
yield this.getLocalData(dataset, studies);
|
||||
yield this.getDataByImageType(dataset);
|
||||
yield this.getDataByDatasetType(dataset);
|
||||
}
|
||||
|
||||
findDicomDataPromise(dataset, studies) {
|
||||
const loaderIterator = this.getLoaderIterator(dataset, studies);
|
||||
// it returns first valid retriever method.
|
||||
for (const loader of loaderIterator) {
|
||||
if (loader) {
|
||||
return loader;
|
||||
}
|
||||
}
|
||||
|
||||
// in case of no valid loader
|
||||
throw new Error('Invalid dicom data loader');
|
||||
}
|
||||
}
|
||||
|
||||
const dicomLoaderService = new DicomLoaderService();
|
||||
|
||||
export default dicomLoaderService;
|
||||
@@ -0,0 +1,115 @@
|
||||
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, // PatientName
|
||||
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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import ObjectPath from './objectPath';
|
||||
import StackManager from './StackManager.js';
|
||||
import absoluteUrl from './absoluteUrl';
|
||||
import guid from './guid';
|
||||
import sortBy from './sortBy.js';
|
||||
import sortBySeriesDate from './sortBySeriesDate.js';
|
||||
import writeScript from './writeScript.js';
|
||||
import DicomLoaderService from './dicomLoaderService.js';
|
||||
import b64toBlob from './b64toBlob.js';
|
||||
//import loadAndCacheDerivedDisplaySets from './loadAndCacheDerivedDisplaySets.js';
|
||||
import urlUtil from './urlUtil';
|
||||
@@ -22,9 +20,13 @@ import progressTrackingUtils from './progressTrackingUtils';
|
||||
import isLowPriorityModality from './isLowPriorityModality';
|
||||
import { isImage } from './isImage';
|
||||
import isDisplaySetReconstructable from './isDisplaySetReconstructable';
|
||||
import imageIdToURI from './imageIdToURI';
|
||||
import debounce from './debounce';
|
||||
import roundNumber from './roundNumber';
|
||||
import downloadCSVReport from './downloadCSVReport';
|
||||
|
||||
// Commented out unused functionality.
|
||||
// Need to implement new mechanism for dervived displaySets using the displaySetManager.
|
||||
// Need to implement new mechanism for derived displaySets using the displaySetManager.
|
||||
|
||||
const utils = {
|
||||
guid,
|
||||
@@ -36,9 +38,8 @@ const utils = {
|
||||
formatDate,
|
||||
formatPN,
|
||||
b64toBlob,
|
||||
StackManager,
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
imageIdToURI,
|
||||
//loadAndCacheDerivedDisplaySets,
|
||||
makeDeferred,
|
||||
makeCancelable,
|
||||
@@ -51,6 +52,9 @@ const utils = {
|
||||
isLowPriorityModality,
|
||||
isImage,
|
||||
isDisplaySetReconstructable,
|
||||
debounce,
|
||||
roundNumber,
|
||||
downloadCSVReport,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -61,8 +65,6 @@ export {
|
||||
formatDate,
|
||||
writeScript,
|
||||
b64toBlob,
|
||||
StackManager,
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
//loadAndCacheDerivedDisplaySets,
|
||||
makeDeferred,
|
||||
@@ -76,6 +78,10 @@ export {
|
||||
isLowPriorityModality,
|
||||
isImage,
|
||||
isDisplaySetReconstructable,
|
||||
imageIdToURI,
|
||||
debounce,
|
||||
roundNumber,
|
||||
downloadCSVReport,
|
||||
};
|
||||
|
||||
export default utils;
|
||||
@@ -10,14 +10,16 @@ describe('Top level exports', () => {
|
||||
'sortBySeriesDate',
|
||||
'isLowPriorityModality',
|
||||
'writeScript',
|
||||
'debounce',
|
||||
'downloadCSVReport',
|
||||
'imageIdToURI',
|
||||
'roundNumber',
|
||||
'b64toBlob',
|
||||
'StackManager',
|
||||
'formatDate',
|
||||
'formatPN',
|
||||
//'loadAndCacheDerivedDisplaySets',
|
||||
'isDisplaySetReconstructable',
|
||||
'isImage',
|
||||
'DicomLoaderService',
|
||||
'urlUtil',
|
||||
'makeDeferred',
|
||||
'makeCancelable',
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import toNumber from './toNumber';
|
||||
|
||||
// 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.
|
||||
*
|
||||
@@ -8,7 +14,7 @@ export default function isDisplaySetReconstructable(instances) {
|
||||
return { value: false };
|
||||
}
|
||||
|
||||
const firstInstance = instances[0].getData().metadata;
|
||||
const firstInstance = instances[0];
|
||||
|
||||
const Modality = firstInstance.Modality;
|
||||
const isMultiframe = firstInstance.NumberOfFrames > 1;
|
||||
@@ -35,19 +41,21 @@ function processMultiframe(instance) {
|
||||
}
|
||||
|
||||
function processSingleframe(instances) {
|
||||
const firstImage = instances[0].getData().metadata;
|
||||
const firstImageRows = firstImage.Rows;
|
||||
const firstImageColumns = firstImage.Columns;
|
||||
const firstImageSamplesPerPixel = firstImage.SamplesPerPixel;
|
||||
const firstImageOrientationPatient = firstImage.ImageOrientationPatient;
|
||||
const firstImagePositionPatient = firstImage.ImagePositionPatient;
|
||||
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].getData().metadata;
|
||||
const instance = instances[i];
|
||||
const {
|
||||
Rows,
|
||||
Columns,
|
||||
@@ -55,11 +63,13 @@ function processSingleframe(instances) {
|
||||
ImageOrientationPatient,
|
||||
} = instance;
|
||||
|
||||
const imageOrientationPatient = toNumber(ImageOrientationPatient);
|
||||
|
||||
if (
|
||||
Rows !== firstImageRows ||
|
||||
Columns !== firstImageColumns ||
|
||||
SamplesPerPixel !== firstImageSamplesPerPixel ||
|
||||
!_isSameOrientation(ImageOrientationPatient, firstImageOrientationPatient)
|
||||
!_isSameOrientation(imageOrientationPatient, firstImageOrientationPatient)
|
||||
) {
|
||||
return { value: false };
|
||||
}
|
||||
@@ -71,8 +81,9 @@ function processSingleframe(instances) {
|
||||
// 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 = instances[instances.length - 1].getData().metadata
|
||||
.ImagePositionPatient;
|
||||
const lastIpp = toNumber(
|
||||
instances[instances.length - 1].ImagePositionPatient
|
||||
);
|
||||
|
||||
// We can't reconstruct if we are missing ImagePositionPatient values
|
||||
if (!firstImagePositionPatient || !lastIpp) {
|
||||
@@ -86,11 +97,12 @@ function processSingleframe(instances) {
|
||||
let previousImagePositionPatient = firstImagePositionPatient;
|
||||
|
||||
for (let i = 1; i < instances.length; i++) {
|
||||
const instance = instances[i].getData().metadata;
|
||||
const { ImagePositionPatient } = instance;
|
||||
const instance = instances[i];
|
||||
// Todo: get metadata from OHIF.MetadataProvider
|
||||
const imagePositionPatient = toNumber(instance.ImagePositionPatient);
|
||||
|
||||
const spacingBetweenFrames = _getPerpendicularDistance(
|
||||
ImagePositionPatient,
|
||||
imagePositionPatient,
|
||||
previousImagePositionPatient
|
||||
);
|
||||
const spacingIssue = _getSpacingIssue(
|
||||
@@ -108,7 +120,7 @@ function processSingleframe(instances) {
|
||||
}
|
||||
}
|
||||
|
||||
previousImagePositionPatient = ImagePositionPatient;
|
||||
previousImagePositionPatient = imagePositionPatient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +139,6 @@ function _isSameOrientation(iop1, iop2) {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Is 10% a reasonable spacingTolerance for spacing?
|
||||
const spacingTolerance = 0.1;
|
||||
const iopTolerance = 0.01;
|
||||
|
||||
/**
|
||||
* Checks for spacing issues.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
function _round(value, decimals) {
|
||||
return Number(value.toFixed(decimals));
|
||||
}
|
||||
|
||||
export default _round;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user