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>
No files matched your search
@@ -8,7 +8,7 @@ const outputFolder = path.join(__dirname, '../dist');
|
||||
const config = {
|
||||
mode: 'production',
|
||||
entry: rootDir + '/' + pkg.module,
|
||||
devtool: 'inline-source-map',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
path: outputFolder,
|
||||
filename: outputFile,
|
||||
@@ -25,23 +25,35 @@ const config = {
|
||||
commonjs: 'react',
|
||||
amd: 'react',
|
||||
},
|
||||
'@ohif/core': {
|
||||
commonjs2: '@ohif/core',
|
||||
commonjs: '@ohif/core',
|
||||
amd: '@ohif/core',
|
||||
root: '@ohif/core',
|
||||
},
|
||||
'@ohif/ui': {
|
||||
commonjs2: '@ohif/ui',
|
||||
commonjs: '@ohif/ui',
|
||||
amd: '@ohif/ui',
|
||||
root: '@ohif/ui',
|
||||
},
|
||||
},
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /(\.jsx|\.js)$/,
|
||||
test: /(\.jsx|\.js|\.tsx|\.ts)$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx',],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
modules: [path.resolve('./node_modules'), path.resolve('./src')],
|
||||
extensions: ['.json', '.js', '.jsx'],
|
||||
extensions: ['.json', '.js', '.jsx', '.tsx', '.ts',],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
modules: 'commonjs',
|
||||
debug: false,
|
||||
},
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
'@babel/preset-react',
|
||||
],
|
||||
@@ -25,6 +26,7 @@ module.exports = {
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
},
|
||||
@@ -33,6 +35,7 @@ module.exports = {
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-i18next": "^10.11.0",
|
||||
"react-router": "^6.2.1",
|
||||
"react-router-dom": "^6.2.1",
|
||||
"react-router": "^6.3.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"webpack": "^5.50.0",
|
||||
"webpack-merge": "^5.7.3"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ const outputFolder = path.join(__dirname, '../dist');
|
||||
const config = {
|
||||
mode: 'production',
|
||||
entry: rootDir + '/' + pkg.module,
|
||||
devtool: 'inline-source-map',
|
||||
devtool: 'source-map',
|
||||
output: {
|
||||
path: outputFolder,
|
||||
filename: outputFile,
|
||||
@@ -25,23 +25,35 @@ const config = {
|
||||
commonjs: 'react',
|
||||
amd: 'react',
|
||||
},
|
||||
'@ohif/core': {
|
||||
commonjs2: '@ohif/core',
|
||||
commonjs: '@ohif/core',
|
||||
amd: '@ohif/core',
|
||||
root: '@ohif/core',
|
||||
},
|
||||
'@ohif/ui': {
|
||||
commonjs2: '@ohif/ui',
|
||||
commonjs: '@ohif/ui',
|
||||
amd: '@ohif/ui',
|
||||
root: '@ohif/ui',
|
||||
},
|
||||
},
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /(\.jsx|\.js)$/,
|
||||
test: /(\.jsx|\.js|\.tsx|\.ts)$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: /(node_modules|bower_components)/,
|
||||
resolve: {
|
||||
extensions: ['.js', '.jsx'],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx',],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
modules: [path.resolve('./node_modules'), path.resolve('./src')],
|
||||
extensions: ['.json', '.js', '.jsx'],
|
||||
extensions: ['.json', '.js', '.jsx', '.tsx', '.ts',],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
modules: 'commonjs',
|
||||
debug: false,
|
||||
},
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
'@babel/preset-react',
|
||||
],
|
||||
@@ -25,6 +26,7 @@ module.exports = {
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
},
|
||||
@@ -33,6 +35,7 @@ module.exports = {
|
||||
// WebPack handles ES6 --> Target Syntax
|
||||
['@babel/preset-env', { modules: false }],
|
||||
'@babel/preset-react',
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
|
||||
|
||||
@@ -31,11 +31,12 @@
|
||||
"@babel/plugin-transform-arrow-functions": "^7.2.0",
|
||||
"@babel/plugin-transform-regenerator": "^7.4.5",
|
||||
"@babel/plugin-transform-runtime": "^7.5.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"@babel/preset-env": "^7.5.0",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/preset-typescript": "^7.17.12",
|
||||
"babel-eslint": "^8.0.3",
|
||||
"babel-loader": "^8.0.0-beta.4",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"copy-webpack-plugin": "^10.2.0",
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -11,7 +11,6 @@ module.exports = (env, argv) => {
|
||||
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
|
||||
|
||||
return merge(commonConfig, {
|
||||
devtool: 'source-map',
|
||||
stats: {
|
||||
colors: true,
|
||||
hash: true,
|
||||
|
||||
@@ -30,16 +30,14 @@
|
||||
"test:unit:ci": "jest --ci --runInBand --collectCoverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cornerstone-core": "^2.6.0",
|
||||
"cornerstone-tools": "6.0.2",
|
||||
"cornerstone-math": "0.1.9",
|
||||
"cornerstone-wado-image-loader": "4.0.4",
|
||||
"cornerstone-wado-image-loader": "^4.2.0",
|
||||
"dicom-parser": "^1.8.9",
|
||||
"@ohif/ui": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.16.3",
|
||||
"dcmjs": "0.16.1",
|
||||
"dcmjs": "^0.24.5",
|
||||
"dicomweb-client": "^0.6.0",
|
||||
"isomorphic-base64": "^1.0.2",
|
||||
"lodash.merge": "^4.6.1",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Key features:
|
||||
|
||||
- Designed to load large radiology studies as quickly as possible. Retrieves
|
||||
metadata ahead of time and streams in imaging pixel data as needed.
|
||||
- Leverages [Cornerstone.js](https://cornerstonejs.org/) for decoding,
|
||||
- Leverages [Cornerstone3D](https://github.com/cornerstonejs/cornerstone3D-beta) for decoding,
|
||||
rendering, and annotating medical images.
|
||||
- Works out-of-the-box with Image Archives that support [DICOMWeb][dicom-web].
|
||||
Offers a Data Source API for communicating with archives over proprietary API
|
||||
|
||||
@@ -24,15 +24,16 @@ you'll see the following:
|
||||
```bash
|
||||
│
|
||||
├── extensions
|
||||
│ ├── _example # Skeleton of example extension
|
||||
│ ├── default # default functionalities
|
||||
│ ├── cornerstone # 2D images w/ Cornerstone.js
|
||||
│ ├── measurement-tracking # measurement tracking
|
||||
│ ├── dicom-sr # Structured reports
|
||||
│ └── dicom-pdf # View DICOM wrapped PDFs in viewport
|
||||
│ ├── _example # Skeleton of example extension
|
||||
│ ├── default # default functionalities
|
||||
│ ├── cornerstone # 2D/3D images w/ Cornerstonejs
|
||||
│ ├── cornerstone-dicom-sr # Structured reports
|
||||
│ ├── measurement-tracking # measurement tracking
|
||||
│ └── dicom-pdf # View DICOM wrapped PDFs in viewport
|
||||
│
|
||||
├── modes
|
||||
│ └── longitudinal # longitudinal measurement tracking mode
|
||||
| └── basic-dev-mode # basic viewer with Cornerstone (a developer focused mode)
|
||||
│
|
||||
├── platform
|
||||
│ ├── core # Business Logic
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
sidebar_label: Continous Integration
|
||||
sidebar_label: Continuos Integration
|
||||
---
|
||||
|
||||
# Continous Integration (CI)
|
||||
# Continuos Integration (CI)
|
||||
|
||||
This repository uses `CircleCI` and `Netlify` for continous integration.
|
||||
This repository uses `CircleCI` and `Netlify` for Continuos integration.
|
||||
|
||||
## Deploy Previews
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ up-to-date with the upstream (original) repository. This is called a "Triangular
|
||||
Workflow" and is common for Open Source projects. The GitHub blog has a [good
|
||||
graphic that illustrates this setup][triangular-workflow].
|
||||
|
||||
### `v3-stable` branch
|
||||
Currently the stable branch for OHIF-v3 is `v3-stable`. Once the v3-stable branch has
|
||||
feature parity with the master branch, `v3-stable` will be pushed to the master branch.
|
||||
You can read more about the roadmap timeline [here](https://ohif.org/roadmap).
|
||||
|
||||
### Private
|
||||
|
||||
Alternatively, if you intend to use the OHIF Viewer as a starting point, and you
|
||||
@@ -106,6 +111,6 @@ yarn run build
|
||||
[add-remote-repo]: https://help.github.com/en/articles/fork-a-repo#step-3-configure-git-to-sync-your-fork-with-the-original-spoon-knife-repository
|
||||
[sync-changes]: https://help.github.com/en/articles/syncing-a-fork
|
||||
[triangular-workflow]: https://github.blog/2015-07-29-git-2-5-including-multiple-worktrees-and-triangular-workflows/#improved-support-for-triangular-workflows
|
||||
[ohif-viewers-repo]: https://github.com/OHIF/Viewers
|
||||
[ohif-viewers-repo]: https://github.com/OHIF/Viewers/tree/v3-stable
|
||||
[ohif-viewers]: https://github.com/OHIF/Viewers
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -65,6 +65,7 @@ export default {
|
||||
getContextModule() { /* */ },
|
||||
getToolbarModule() { /* */ },
|
||||
getHangingProtocolModule() { /* */ },
|
||||
getUtilityModule() { /* */ },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,7 +87,7 @@ the top level [`extensions/`][ext-source] directory.
|
||||
<tr>
|
||||
<td>
|
||||
<a href="">
|
||||
Default
|
||||
default
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
@@ -97,18 +98,18 @@ the top level [`extensions/`][ext-source] directory.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-cornerstone">
|
||||
Cornerstone
|
||||
<a href="">
|
||||
cornerstone
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
Provides rendering functionalities for 2D images.
|
||||
Provides 2d and 3d rendering functionalities
|
||||
</td>
|
||||
<td>ViewportModule, CommandsModule</td>
|
||||
<td>ViewportModule, CommandsModule, UtilityModule</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-dicom-pdf">DICOM PDF</a>
|
||||
<a href="">dicom-pdf</a>
|
||||
</td>
|
||||
<td>
|
||||
Renders PDFs for a <a href="https://github.com/OHIF/Viewers/blob/master/extensions/dicom-pdf/src/OHIFDicomPDFSopClassHandler.js#L4-L6">specific SopClassUID</a>.
|
||||
@@ -117,7 +118,16 @@ the top level [`extensions/`][ext-source] directory.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="">DICOM SR</a>
|
||||
<a href="">dicom-video</a>
|
||||
</td>
|
||||
<td>
|
||||
Renders DICOM Video files.
|
||||
</td>
|
||||
<td>Viewport, SopClassHandler</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="">cornerstone-dicom-sr</a>
|
||||
</td>
|
||||
<td>
|
||||
Maintained extensions for cornerstone and visualization of DICOM Structured Reports
|
||||
@@ -126,7 +136,7 @@ the top level [`extensions/`][ext-source] directory.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="">Measurement tracking</a>
|
||||
<a href="">measurement-tracking</a>
|
||||
</td>
|
||||
<td>
|
||||
Tracking measurements in the measurement panel
|
||||
@@ -290,6 +300,14 @@ differently.
|
||||
</td>
|
||||
<td align="left">Adds hanging protocol rules</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<a href="./modules/hpModule">
|
||||
Utility
|
||||
</a>
|
||||
</td>
|
||||
<td align="left">Expose utility functions to the outside of extensions</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ Extensions can implement specific lifecycle methods.
|
||||
## preRegistration
|
||||
|
||||
If an extension defines the `preRegistration` lifecycle hook, it is called
|
||||
before any modules are registered in the `ExtensionManager`. This hook can be
|
||||
used to:
|
||||
before any modules are registered in the `ExtensionManager`. This hook is an
|
||||
`async` function that can be used to perform:
|
||||
|
||||
- initialize 3rd party libraries
|
||||
- register event listeners
|
||||
@@ -61,7 +61,7 @@ export default {
|
||||
* @param {CommandsManager} params.commandsManager
|
||||
* @returns void
|
||||
*/
|
||||
preRegistration({ servicesManager, commandsManager, configuration }) {
|
||||
async preRegistration({ servicesManager, commandsManager, configuration }) {
|
||||
console.log('Wiring up important stuff.');
|
||||
|
||||
window.importantStuff = () => {
|
||||
@@ -90,7 +90,7 @@ _Example `onModeEnter` hook implementation_
|
||||
|
||||
```js
|
||||
export default {
|
||||
id: '@ohif/extension-dicom-sr',
|
||||
id: '@ohif/extension-cornerstone-dicom-sr',
|
||||
|
||||
onModeEnter({ servicesManager }) {
|
||||
const { DisplaySetService } = servicesManager.services;
|
||||
|
||||
@@ -22,26 +22,40 @@ const deafultProtocol = {
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
toolGroupIds: ['default'],
|
||||
stages: [
|
||||
{
|
||||
id: 'nwzau7jDkEkL8djfr',
|
||||
name: 'oneByOne',
|
||||
id: 'hYbmMy3b7pz7GLiaT',
|
||||
name: 'default',
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
columns: 2,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
displaySets: [
|
||||
{
|
||||
viewportSettings: [],
|
||||
id: 'displaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'default',
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: [],
|
||||
id: 'displaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
createdDate: '2021-02-23T18:32:42.850Z',
|
||||
},
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
@@ -51,8 +65,385 @@ function getHangingProtocolModule() {
|
||||
return [
|
||||
{
|
||||
name: hangingProtocolName,
|
||||
protocols: [deafultProtocol],
|
||||
protocols: [defaultProtocol],
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Skeleton of A Hanging Protocol
|
||||
|
||||
The skeleton of a hanging protocol is as follows:
|
||||
|
||||
|
||||
```js
|
||||
const testProtocol = {
|
||||
id: 'test',
|
||||
locked: true,
|
||||
hasUpdatedPriorsInformation: false,
|
||||
name: 'Default',
|
||||
createdDate: '2021-02-23T19:22:08.894Z',
|
||||
modifiedDate: '2021-02-23T19:22:08.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
toolGroupIds: ['default'],
|
||||
protocolMatchingRules: [
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'PETCT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'PET/CT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
stages: [
|
||||
{
|
||||
id: 'hYbmMy3b7pz7GLiaT',
|
||||
name: 'default',
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
viewportOptions: [],
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
{
|
||||
id: 'ptACDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'PT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
{
|
||||
id: 'ptNACDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'Uncorrected',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAxial',
|
||||
viewportType: 'stack',
|
||||
background: [0, 0, 0],
|
||||
orientation: 'AXIAL',
|
||||
toolGroupId: 'default',
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ptAxial',
|
||||
viewportType: 'stack',
|
||||
background: [1, 1, 1],
|
||||
orientation: 'AXIAL',
|
||||
toolGroupId: 'default',
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptACDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
createdDate: '2021-02-23T18:32:42.850Z',
|
||||
},
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
};
|
||||
```
|
||||
|
||||
Let's discuss each property in depth.
|
||||
|
||||
### Id
|
||||
unique identifier for the protocol
|
||||
|
||||
### protocolMatchingRules
|
||||
A list of criteria for the protocol along with the provided points for ranking.
|
||||
|
||||
- `weight`: weight for the matching rule. Eventually, all the registered
|
||||
protocols get sorted based on the weights, and the winning protocol gets
|
||||
applied to the viewer.
|
||||
- `attribute`: tag that needs to be matched against. This can be either
|
||||
Study-level metadata or a custom attribute.
|
||||
[Learn more about custom attribute matching](#custom-attribute)
|
||||
|
||||
- `constraint`: the constraint that needs to be satisfied for the attribute. It accepts a `validator` which can be
|
||||
[`equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWidth`]
|
||||
|
||||
A sample of the matching rule is above which matches against the study description to be PETCT
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'PETCT',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
```
|
||||
|
||||
### stages
|
||||
Each protocol can define one or more stages. Each stage defines a certain layout and viewport rules.
|
||||
Therefore, the `stages` property is array of objects, each object being one stage.
|
||||
|
||||
### viewportStructure
|
||||
Defines the layout of the viewer. You can define the number of `rows` and `columns`. There should be `rows * columns` number of
|
||||
viewport configuration in the `viewports` property. Note that order of viewports are rows first then columns.
|
||||
|
||||
```js
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
viewportOptions: [],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
In addition to the equal viewport sizes, you can define viewports to span multiple rows or columns.
|
||||
|
||||
```js
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
viewportOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1 / 4,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 1 / 4,
|
||||
y: 0,
|
||||
width: 3 / 4,
|
||||
height: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### displaySets
|
||||
Defines the display sets that are available for the stage. Each
|
||||
include an `id` and some `seriesMatchingRules`
|
||||
|
||||
```js
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
{
|
||||
id: 'ptACDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'PT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
{
|
||||
id: 'ptNACDisplaySet',
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'PT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'GPEYqFLv2dwzCM322',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'Uncorrected',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
As you see in the above `displaySets` entry in the example above, there are
|
||||
three defined displaySets that __will__ become available later on in the `viewports`
|
||||
section of the protocol.
|
||||
|
||||
|
||||
1. `ctDisplaySet` which is matched against the CT modality
|
||||
2. `ptACDisplaySet` which is matched against the PT modality
|
||||
3. `ptNACDisplaySet` which is matched against the PT modality and also contains the `Uncorrected` series description
|
||||
|
||||
### viewports
|
||||
This field includes the viewports that will get hung on the viewer.
|
||||
|
||||
```js
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ctAxial',
|
||||
viewportType: 'stack',
|
||||
background: [0, 0, 0],
|
||||
orientation: 'AXIAL',
|
||||
toolGroupId: 'default',
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'ctDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'ptAxial',
|
||||
viewportType: 'stack',
|
||||
background: [1, 1, 1],
|
||||
orientation: 'AXIAL',
|
||||
toolGroupId: 'default',
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
options: {
|
||||
voi: {
|
||||
windowWidth: 5,
|
||||
windowCenter: 2.5,
|
||||
},
|
||||
voiInverted: true,
|
||||
},
|
||||
id: 'ptACDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
As you can see there are two viewports defined. The first one is a CT viewport and the second one is a PT viewport.
|
||||
|
||||
Each viewport has two properties:
|
||||
|
||||
1. `viewportOptions`: defines the viewport properties such as
|
||||
- `viewportId`: unique identifier for the viewport (optional)
|
||||
- `viewportType`: type of the viewport (required - currently only stack)
|
||||
- `background`: background color of the viewport (optional)
|
||||
- `orientation`: orientation of the viewport (optional)
|
||||
- `toolGroupId`: tool group that will be used for the viewport (optional)
|
||||
|
||||
|
||||
2. `displaySets`: defines the display sets that are available for the viewport. It is an array of objects, each object being one display set.
|
||||
- `id`: id of the display set (required)
|
||||
- `options` (optional): options for the display set
|
||||
- voi: windowing options for the display set
|
||||
- voiInverted: whether the VOI is inverted or not (optional)
|
||||
@@ -72,7 +72,7 @@ const extensionDependencies = {
|
||||
'@ohif/extension-default': '^3.0.0',
|
||||
'@ohif/extension-cornerstone': '^3.0.0',
|
||||
'@ohif/extension-measurement-tracking': '^3.0.0',
|
||||
'@ohif/extension-dicom-sr': '^3.0.0',
|
||||
'@ohif/extension-cornerstone-dicom-sr': '^3.0.0',
|
||||
};
|
||||
|
||||
const id = 'viewer'
|
||||
|
||||
@@ -89,25 +89,32 @@ The simplest toolbarButtons definition has the following properties:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'Zoom',
|
||||
type: 'ohif.radioGroup',
|
||||
props: {
|
||||
type: 'tool',
|
||||
icon: 'tool-zoom',
|
||||
label: 'Zoom',
|
||||
commandOptions: { toolName: 'Zoom' },
|
||||
},
|
||||
},
|
||||
"id": "Zoom",
|
||||
"type": "ohif.radioGroup",
|
||||
"props": {
|
||||
"type": "tool",
|
||||
"icon": "tool-zoom",
|
||||
"label": "Zoom",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Zoom"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| property | description | values |
|
||||
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------- |
|
||||
| `id` | Unique string identifier for the definition | \* |
|
||||
| `label` | User/display friendly to show in UI | \* |
|
||||
| `icon` | A string name for an icon supported by the consuming application. | \* |
|
||||
| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" |
|
||||
| `commandName` | (optional) The command to run when the button is used. | Any command registered by a `CommandModule` |
|
||||
| `commandOptions` | (optional) Options to pass the target `commandName` | \* |
|
||||
| `icon` | A string name for an icon supported by the consuming application. | \* |
|
||||
| `label` | User/display friendly to show in UI | \* |
|
||||
| `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` |
|
||||
|
||||
There are three main types of toolbar buttons:
|
||||
|
||||
@@ -133,59 +140,92 @@ to create `MeasurementTools` nested button
|
||||
|
||||
```js title="modes/longitudinal/src/toolbarButtons.js"
|
||||
{
|
||||
id: 'MeasurementTools',
|
||||
type: 'ohif.splitButton',
|
||||
props: {
|
||||
groupId: 'MeasurementTools',
|
||||
isRadio: true,
|
||||
primary: {
|
||||
id: 'Length',
|
||||
icon: 'tool-length',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Length',
|
||||
}
|
||||
"id": "MeasurementTools",
|
||||
"type": "ohif.splitButton",
|
||||
"props": {
|
||||
"groupId": "MeasurementTools",
|
||||
"isRadio": true,
|
||||
"primary": {
|
||||
"id": "Length",
|
||||
"icon": "tool-length",
|
||||
"label": "Length",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Length"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRLength",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Length"
|
||||
},
|
||||
secondary: {
|
||||
icon: 'chevron-down',
|
||||
label: '',
|
||||
isActive: true,
|
||||
tooltip: 'More Measure Tools',
|
||||
"secondary": {
|
||||
"icon": "chevron-down",
|
||||
"label": "",
|
||||
"isActive": true,
|
||||
"tooltip": "More Measure Tools"
|
||||
},
|
||||
items: [
|
||||
// Length tool
|
||||
"items": [
|
||||
{
|
||||
id: 'Length',
|
||||
icon: 'tool-length',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Length',
|
||||
}
|
||||
"id": "Bidirectional",
|
||||
"icon": "tool-bidirectional",
|
||||
"label": "Bidirectional",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Bidirectional"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRBidirectional",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Bidirectional Tool"
|
||||
},
|
||||
// Bidirectional tool
|
||||
{
|
||||
id: 'Bidirectional',
|
||||
icon: 'tool-bidirectional',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Bidirectional',
|
||||
}
|
||||
"id": "ArrowAnnotate",
|
||||
"icon": "tool-annotate",
|
||||
"label": "Annotation",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "ArrowAnnotate"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRArrowAnnotate",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Arrow Annotate"
|
||||
},
|
||||
// Ellipse tool
|
||||
{
|
||||
id: 'EllipticalRoi',
|
||||
icon: 'tool-elipse',
|
||||
label: 'Ellipse',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'EllipticalRoi',
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
sidebar_label: Utility
|
||||
---
|
||||
|
||||
# Module: Utility
|
||||
|
||||
## Overview
|
||||
|
||||
Often, an extension will need to expose some useful functionality to the other
|
||||
extensions, or modes that consume the extension. For example, the `cornerstone`
|
||||
extension, uses its `utility` module to expose methods via
|
||||
|
||||
```js
|
||||
getUtilityModule({ servicesManager }) {
|
||||
return [
|
||||
{
|
||||
name: 'common',
|
||||
exports: {
|
||||
getCornerstoneLibraries: () => {
|
||||
return { cornerstone, cornerstoneTools };
|
||||
},
|
||||
getEnabledElement,
|
||||
CornerstoneViewportService,
|
||||
dicomLoaderService,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'core',
|
||||
exports: {
|
||||
Enums: cs3DEnums,
|
||||
CONSTANTS,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
exports: {
|
||||
toolNames,
|
||||
Enums: cs3DToolsEnums,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Then a consuming extension can use `getModuleEntry` to access the methods
|
||||
Below, which is a code from `TrackedCornerstoneViewport` use the `getUtilityModule` method to get the internal `CornerstoneViewportService` which handles the `Cornerstone` viewport.
|
||||
|
||||
```js title="extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx"
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
const { CornerstoneViewportService } = utilityModule.exports;
|
||||
const viewportId = CornerstoneViewportService.getViewportId(viewportIndex);
|
||||
```
|
||||
@@ -14,10 +14,7 @@ add support for:
|
||||
|
||||
- 2D Medical Image Viewing (cornerstone ext.)
|
||||
- Structured Reports as SR (DICOM SR ext.)
|
||||
- Structured Reports as HTML (DICOM html ext.)
|
||||
- Encapsulated PDFs as PDFs (DICOM pdf ext.)
|
||||
- Whole Slide Microscopy Viewing (whole slide ext.)
|
||||
- etc.
|
||||
|
||||
The general pattern is that a mode can define which `Viewport` to use for which
|
||||
specific `SOPClassHandlerUID`, so if you want to fork just a single Viewport
|
||||
@@ -43,44 +40,53 @@ const getViewportModule = () => {
|
||||
|
||||
## Example Viewport Component
|
||||
|
||||
A simplified version of the tracked CornerstoneViewport is shown below, which
|
||||
creates a cornerstone viewport and action bar on top of it.
|
||||
A simplified version of the tracked `OHIFCornerstoneViewport` is shown below, which
|
||||
creates a cornerstone viewport:
|
||||
|
||||
:::note Tip
|
||||
|
||||
Not in OHIF version 3.1 we use `displaySets` in the props which is new compared to
|
||||
the previous version (3.0) which uses `displaySet`. This is due to the fact that
|
||||
we are moving to a new data model that can render fused images in a single viewport.
|
||||
|
||||
:::
|
||||
|
||||
```jsx
|
||||
function TrackedCornerstoneViewport({
|
||||
children,
|
||||
dataSource,
|
||||
displaySet,
|
||||
displaySets,
|
||||
viewportIndex,
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
}) {
|
||||
const renderViewport = () => {
|
||||
const { component: Component } = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.viewportModule.cornerstone'
|
||||
);
|
||||
return (
|
||||
<Component
|
||||
onElementEnabled={onElementEnabled}
|
||||
element={element}
|
||||
{...props}
|
||||
></Component>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewportActionBar onDoubleClick onSeriesChange />
|
||||
<div className="relative flex flex-row w-full h-full overflow-hidden">
|
||||
{renderViewport()}
|
||||
</div>
|
||||
</>
|
||||
<div className="viewport-wrapper">
|
||||
/** Resize Detector */
|
||||
<ReactResizeDetector
|
||||
handleWidth
|
||||
handleHeight
|
||||
skipOnMount={true} // Todo: make these configurable
|
||||
refreshMode={'debounce'}
|
||||
refreshRate={100}
|
||||
onResize={onResize}
|
||||
targetRef={elementRef.current}
|
||||
/>
|
||||
/** Div For displaying image */
|
||||
<div
|
||||
className="cornerstone-viewport-element"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
ref={elementRef}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
### `@ohif/viewer`
|
||||
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
sidebar_position: 3
|
||||
sidebar_label: Service Manager
|
||||
---
|
||||
|
||||
# Services Manager
|
||||
|
||||
## Overview
|
||||
Services manager is the single point of service registration. Each service needs to implement a `create` method which gets called inside `ServicesManager` to instantiate the service. In the app, you can get access to a registered service via the `services` property of the `ServicesManager`.
|
||||
|
||||
Services manager is the single point of service registration. Each service needs
|
||||
to implement a `create` method which gets called inside `ServicesManager` to
|
||||
instantiate the service. In the app, you can get access to a registered service
|
||||
via the `services` property of the `ServicesManager`.
|
||||
|
||||
## Skeleton
|
||||
*Simplified* skeleton of `ServicesManager` is shown below. There are two public methods:
|
||||
|
||||
_Simplified_ skeleton of `ServicesManager` is shown below. There are two public
|
||||
methods:
|
||||
|
||||
- `registerService`: registering a new service with/without a configuration
|
||||
- `registerServices`: registering batch of services
|
||||
|
||||
|
||||
```js
|
||||
export default class ServicesManager {
|
||||
constructor(commandsManager) {
|
||||
this._commandsManager = commandsManager
|
||||
this.services = {}
|
||||
this.registeredServiceNames = []
|
||||
this._commandsManager = commandsManager;
|
||||
this.services = {};
|
||||
this.registeredServiceNames = [];
|
||||
}
|
||||
|
||||
registerService(service, configuration = {}) {
|
||||
@@ -27,10 +33,10 @@ export default class ServicesManager {
|
||||
this.services[service.name] = service.create({
|
||||
configuration,
|
||||
commandsManager: this._commandsManager,
|
||||
})
|
||||
});
|
||||
|
||||
/* Track service registration */
|
||||
this.registeredServiceNames.push(service.name)
|
||||
this.registeredServiceNames.push(service.name);
|
||||
}
|
||||
|
||||
registerServices(services) {
|
||||
@@ -39,32 +45,32 @@ export default class ServicesManager {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Default Registered Services
|
||||
|
||||
By default, `OHIF-v3` registers the following services in the `appInit`.
|
||||
|
||||
```js title="platform/viewer/src/appInit.js"
|
||||
servicesManager.registerServices([
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
UIViewportDialogService,
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
ToolBarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
CineService,
|
||||
]);
|
||||
UINotificationService,
|
||||
UIModalService,
|
||||
UIDialogService,
|
||||
UIViewportDialogService,
|
||||
MeasurementService,
|
||||
DisplaySetService,
|
||||
ToolBarService,
|
||||
ViewportGridService,
|
||||
HangingProtocolService,
|
||||
CineService,
|
||||
]);
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Service Architecture
|
||||
If you take a look at the folder of each service implementation above, you will
|
||||
find out that services need to be exported as an object with `name` and `create` method.
|
||||
|
||||
For instance, `ToolbarService` is exported as:
|
||||
If you take a look at the folder of each service implementation above, you will
|
||||
find out that services need to be exported as an object with `name` and `create`
|
||||
method.
|
||||
|
||||
For instance, `ToolBarService` is exported as:
|
||||
|
||||
```js title="platform/core/src/services/ToolBarService/index.js"
|
||||
import ToolBarService from './ToolBarService';
|
||||
@@ -77,43 +83,45 @@ export default {
|
||||
};
|
||||
```
|
||||
|
||||
and the implementation of `ToolbarService` lies in the same folder at `./ToolbarSerivce.js`.
|
||||
and the implementation of `ToolBarService` lies in the same folder at
|
||||
`./ToolbarSerivce.js`.
|
||||
|
||||
> Note, the create method is critical for any custom service that you write and
|
||||
> want to add to the list of services
|
||||
|
||||
|
||||
|
||||
## Accessing Services
|
||||
Throughout the app you can use `services` property of the service manager to access
|
||||
the desired service.
|
||||
|
||||
For instance in the `PanelMeasurementTableTracking` which is the right panel in the
|
||||
`longitudinal` mode, we have the *simplified code below* for downloading the drawn measurements.
|
||||
Throughout the app you can use `services` property of the service manager to
|
||||
access the desired service.
|
||||
|
||||
For instance in the `PanelMeasurementTableTracking` which is the right panel in
|
||||
the `longitudinal` mode, we have the _simplified code below_ for downloading the
|
||||
drawn measurements.
|
||||
|
||||
```js
|
||||
function PanelMeasurementTableTracking({ servicesManager }) {
|
||||
const { MeasurementService } = servicesManager.services
|
||||
const { MeasurementService } = servicesManager.services;
|
||||
/** ... **/
|
||||
|
||||
async function exportReport() {
|
||||
const measurements = MeasurementService.getMeasurements()
|
||||
const measurements = MeasurementService.getMeasurements();
|
||||
/** ... **/
|
||||
downloadCSVReport(measurements, MeasurementService)
|
||||
downloadCSVReport(measurements, MeasurementService);
|
||||
}
|
||||
|
||||
/** ... **/
|
||||
return <> /** ... **/ </>
|
||||
return <> /** ... **/ </>;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Registering Custom Services
|
||||
You might need to write you own custom service in an extension. `preRegistration` hook inside your extension is the place for registering your custom service.
|
||||
|
||||
You might need to write you own custom service in an extension.
|
||||
`preRegistration` hook inside your extension is the place for registering your
|
||||
custom service.
|
||||
|
||||
```js title="extensions/customExtension/src/index.js"
|
||||
import WrappedBackEndService from './services/backEndService'
|
||||
import WrappedBackEndService from './services/backEndService';
|
||||
|
||||
export default {
|
||||
// ID of the extension
|
||||
@@ -121,12 +129,11 @@ export default {
|
||||
preRegistration({ servicesManager }) {
|
||||
servicesManager.registerService(WrappedBackEndService(servicesManager));
|
||||
},
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
and the logic for your service shall be
|
||||
|
||||
|
||||
```js title="extensions/customExtension/src/services/backEndService/index.js"
|
||||
import backEndService from './backEndService';
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ For instance, in `longitudinal` mode we are using this hook to initialize the
|
||||
`ToolBarService` and set the window level/width tool to be active and add
|
||||
buttons to the toolbar.
|
||||
|
||||
:::note Tip
|
||||
|
||||
In OHIF Version 3.1, there is a new service `ToolGroupService` that is used to
|
||||
define and manage tools for the group of viewports. This is a new concept
|
||||
borrowed from the Cornerstone ToolGroup, and you can read more
|
||||
[here](https://www.cornerstonejs.org/docs/concepts/cornerstone-tools/toolgroups/)
|
||||
|
||||
:::
|
||||
|
||||
```js
|
||||
function modeFactory() {
|
||||
return {
|
||||
@@ -29,16 +38,10 @@ function modeFactory() {
|
||||
version: '',
|
||||
displayName: '',
|
||||
onModeEnter: ({ servicesManager, extensionManager }) => {
|
||||
const { ToolBarService } = servicesManager.services;
|
||||
const { ToolBarService, ToolGroupService } = servicesManager.services;
|
||||
|
||||
const interaction = {
|
||||
groupId: 'primary',
|
||||
itemId: 'Wwwc',
|
||||
interactionType: 'tool',
|
||||
commandOptions: undefined,
|
||||
};
|
||||
|
||||
ToolBarService.recordInteraction(interaction);
|
||||
// Init Default and SR ToolGroups
|
||||
initToolGroups(extensionManager, ToolGroupService);
|
||||
|
||||
ToolBarService.init(extensionManager);
|
||||
ToolBarService.addButtons(toolbarButtons);
|
||||
@@ -65,7 +68,7 @@ This hook is called when the viewer navigate away from the route in the url.
|
||||
This is the place for cleaning up data, and services by unsubscribing to the
|
||||
events.
|
||||
|
||||
For instance, it can be used to reset the `ToolbarService` which reset the
|
||||
For instance, it can be used to reset the `ToolBarService` which reset the
|
||||
toggled buttons.
|
||||
|
||||
```js
|
||||
|
||||
@@ -58,9 +58,9 @@ function modeFactory() {
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: '@ohif/extension-dicom-sr.viewportModule.dicom-sr',
|
||||
namespace: '@ohif/extension-cornerstone-dicom-sr.viewportModule.dicom-sr',
|
||||
displaySetsToDisplay: [
|
||||
'@ohif/extension-dicom-sr.sopClassHandlerModule.dicom-sr',
|
||||
'@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr',
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -204,9 +204,6 @@ init: async ({
|
||||
const instance = displaySet.images.find(
|
||||
image => image.SOPInstanceUID === tool.SOPInstanceUID
|
||||
);
|
||||
|
||||
const { SOPInstanceUID, url } = instance;
|
||||
displaySet.initialImageIdIndex = displaySet.images.indexOf(instance);
|
||||
});
|
||||
|
||||
MeasurementService.addMeasurement(/**...**/);
|
||||
|
||||
@@ -2,205 +2,54 @@
|
||||
sidebar_position: 4
|
||||
sidebar_label: Hanging Protocol Service
|
||||
---
|
||||
|
||||
# Hanging Protocol Service
|
||||
|
||||
## Overview
|
||||
`HangingProtocolService` is a migration of the `OHIF-v1` hanging protocol engine.
|
||||
This service handles the arrangement of the images in the viewport.
|
||||
In short, the registered protocols will get matched with the Series that are available
|
||||
for the series. Each protocol gets a point, and they are ranked. The winning protocol gets
|
||||
applied and its settings run for the viewports.
|
||||
|
||||
You can read more about hanging protocols [here](http://dicom.nema.org/dicom/Conf-2005/Day-2_Selected_Papers/B305_Morgan_HangProto_v1.pdf). In short
|
||||
with `OHIF-v3` hanging protocols you can:
|
||||
`HangingProtocolService` is a migration of the `OHIF-v1` hanging protocol
|
||||
engine. This service handles the arrangement of the images in the viewport. In
|
||||
short, the registered protocols will get matched with the Series that are
|
||||
available for the series. Each protocol gets a point, and they are ranked. The
|
||||
winning protocol gets applied and its settings run for the viewports.
|
||||
|
||||
You can read more about hanging protocols
|
||||
[here](http://dicom.nema.org/dicom/Conf-2005/Day-2_Selected_Papers/B305_Morgan_HangProto_v1.pdf).
|
||||
In short with `OHIF-v3` hanging protocols you can:
|
||||
|
||||
- Define what layout of the viewport should the viewer starts with (2x2 layout)
|
||||
- Define which series gets displayed in which position of the layout
|
||||
- Apply certain initial viewport settings; e.g., inverting the contrast
|
||||
- Enable certain tools based on what series are displayed: link prostate T2 and ADC MRI.
|
||||
|
||||
|
||||
|
||||
|
||||
## Skeleton of A Hanging Protocol
|
||||
You can find the skeleton of the hanging protocols here:
|
||||
|
||||
```js
|
||||
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,
|
||||
};
|
||||
```
|
||||
|
||||
Let's discuss each property in depth.
|
||||
|
||||
|
||||
|
||||
|
||||
- `id`: unique identifier for the protocol
|
||||
|
||||
- `protocolMatchingRules`: A list of criteria for the protocol along with the provided points for ranking.
|
||||
|
||||
- `weight`: weight for the matching rule. Eventually, all the registered protocols get sorted based on the weights, and the winning
|
||||
protocol gets applied to the viewer.
|
||||
- `attriubte`: tag that needs to be matched against. This can be either Study-level metadata or a custom attribute. [Learn more about custom attribute matching](#custom-attribute)
|
||||
|
||||
- `constraint`: the constraint that needs to be satisfied for the attribute. It accepts a `validator` which can be
|
||||
[`equals`, `doesNotEqual`, `contains`, `doesNotContain`, `startsWith`, `endsWidth`]
|
||||
|
||||
A sample of the matching rule is:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'wauZK2QNEfDPwcAQo',
|
||||
weight: 1,
|
||||
attribute: 'StudyInstanceUID',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: '1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
}
|
||||
```
|
||||
|
||||
- `stages`: Each protocol can define one or more stages. Each stage defines a certain layout and viewport rules.
|
||||
Therefore, the `stages` property is array of objects, each object being one stage.
|
||||
|
||||
- `viewportStructure`: Defines the layout of the viewer. You can define the number of `rows` and `columns`.
|
||||
There should be `rows * columns` number of viewport configuration in the `viewports` property. Note that order of viewports are rows first then columns.
|
||||
|
||||
- `viewportSettings`: custom settings to be applied to the viewport. This can be a `voi` being applied
|
||||
to the viewer or a tool to get enabled. We will discuss viewport-specific settings [below](#viewport-settings)
|
||||
|
||||
- `imageMatchingRules (comming soon)`: setting the image slice for the viewport.
|
||||
|
||||
- `seriesMatchingRules`: the most important rule that matches series in the viewport. For instance, the following stage
|
||||
configuration will create a one-by-two layout and put the series whose description contains `t2` on the left, and a series with
|
||||
description that contains `adc` on the right. (order of viewports are rows, first then columns)
|
||||
|
||||
```js
|
||||
stages: [
|
||||
{
|
||||
id: 'hYbmMy3b7pz7GLiaT',
|
||||
name: 'oneByThree',
|
||||
viewportStructure: {
|
||||
type: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 2,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
// viewport 1
|
||||
{
|
||||
viewportSettings: [],
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'vSjk7NCYjtdS3XZAw',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 't2',
|
||||
},
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
// viewport 2
|
||||
{
|
||||
viewportSettings: [],
|
||||
imageMatchingRules: [],
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
id: 'vSjk7NCYjtdS3XZAw',
|
||||
weight: 1,
|
||||
attribute: 'SeriesDescription',
|
||||
constraint: {
|
||||
contains: {
|
||||
value: 'ADC',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
studyMatchingRules: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
- Enable certain tools based on what series are displayed: link prostate T2 and
|
||||
ADC MRI.
|
||||
|
||||
## Events
|
||||
There are two events that get publish in `HangingProtocolService`:
|
||||
|
||||
There are two events that get publish in `HangingProtocolService`:
|
||||
|
||||
| Event | Description |
|
||||
| ------------ | -------------------------------------------------------------------- |
|
||||
| NEW_LAYOUT | Fires when a new layout is requested by the `HangingProtocolService` |
|
||||
| STAGE_CHANGE | Fires when the the stage is changed in the hanging protocols |
|
||||
|
||||
|
||||
## API
|
||||
|
||||
- `getState`: returns an array: `[matchDetails, hpAlreadyApplied]`:
|
||||
|
||||
- `matchDetails`: matching details for the series
|
||||
- `hpAlreadyApplied`: An array which tracks whether HPServices has been applied on each viewport.
|
||||
- `hpAlreadyApplied`: An array which tracks whether HPServices has been
|
||||
applied on each viewport.
|
||||
|
||||
- `addProtocols`: adds provided protocols to the list of registered protocols for matching
|
||||
- `addProtocols`: adds provided protocols to the list of registered protocols
|
||||
for matching
|
||||
|
||||
- `run(studyMetaData, protocol)`: runs the HPService with the provided studyMetaData and optional protocol.
|
||||
If protocol is not given, HP Matching engine will search all the registered protocols for the best matching one based on the constraints.
|
||||
- `run(studyMetaData, protocol)`: runs the HPService with the provided
|
||||
studyMetaData and optional protocol. If protocol is not given, HP Matching
|
||||
engine will search all the registered protocols for the best matching one
|
||||
based on the constraints.
|
||||
|
||||
- `addCustomAttribute`: adding a custom attribute for matching. (see below)
|
||||
|
||||
- `addCustomViewportSetting`: adding a custom setting to a viewport (initial `voi`). Below, we explain
|
||||
in detail how to add custom viewport settings via protocol definitions. `addCustomViewportSetting` is another way to set
|
||||
these settings which is exposed by API
|
||||
|
||||
-
|
||||
|
||||
Default initialization of the modes handles running the `HangingProtocolService`
|
||||
|
||||
## Custom Attribute
|
||||
@@ -208,9 +57,13 @@ In some situations, you might want to match based on a custom attribute and not
|
||||
if you have assigned a `timepointId` to each study, and you want to match based on it.
|
||||
Good news is that, in `OHIF-v3` you can define you custom attribute and use it for matching.
|
||||
|
||||
There are various ways that you can let `HangingProtocolService` know of you custom attribute.
|
||||
We will show how to add it inside the mode configuration.
|
||||
In some situations, you might want to match based on a custom attribute and not
|
||||
the DICOM tags. For instance, if you have assigned a `timepointId` to each study
|
||||
and you want to match based on it. Good news is that, in `OHIF-v3` you can
|
||||
define you custom attribute and use it for matching.
|
||||
|
||||
There are various ways that you can let `HangingProtocolService` know of you
|
||||
custom attribute. We will show how to add it inside the mode configuration.
|
||||
|
||||
```js
|
||||
const deafultProtocol = {
|
||||
@@ -233,13 +86,13 @@ const deafultProtocol = {
|
||||
/** ... **/
|
||||
],
|
||||
numberOfPriorsReferenced: -1,
|
||||
}
|
||||
};
|
||||
|
||||
// Custom function for custom attribute
|
||||
const getTimePointUID = (metaData) => {
|
||||
const getTimePointUID = metaData => {
|
||||
// requesting the timePoint Id
|
||||
return myBackEndAPI(metaData)
|
||||
}
|
||||
return myBackEndAPI(metaData);
|
||||
};
|
||||
|
||||
function modeFactory() {
|
||||
return {
|
||||
@@ -249,49 +102,51 @@ function modeFactory() {
|
||||
{
|
||||
path: 'myModeRoute',
|
||||
init: async ({}) => {
|
||||
const { DicomMetadataStore, HangingProtocolService } =
|
||||
servicesManager.services
|
||||
const {
|
||||
DicomMetadataStore,
|
||||
HangingProtocolService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const onSeriesAdded = ({
|
||||
StudyInstanceUID,
|
||||
madeInClient = false,
|
||||
}) => {
|
||||
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID)
|
||||
const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID);
|
||||
|
||||
// Adding custom attribute to the hangingprotocol
|
||||
HangingProtocolService.addCustomAttribute(
|
||||
'timepoint',
|
||||
'timepoint',
|
||||
(metaData) => getFirstMeasurementSeriesInstanceUID(metaData)
|
||||
)
|
||||
metaData => getFirstMeasurementSeriesInstanceUID(metaData)
|
||||
);
|
||||
|
||||
HangingProtocolService.run(studyMetadata)
|
||||
}
|
||||
HangingProtocolService.run(studyMetadata);
|
||||
};
|
||||
|
||||
DicomMetadataStore.subscribe(
|
||||
DicomMetadataStore.EVENTS.SERIES_ADDED,
|
||||
onSeriesAdded
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
/** ... **/
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Viewport Settings
|
||||
You can define custom settings to be applied to each viewport. There
|
||||
are two types of settings:
|
||||
|
||||
You can define custom settings to be applied to each viewport. There are two
|
||||
types of settings:
|
||||
|
||||
- `viewport settings`: Currently we support two viewport settings
|
||||
|
||||
- `voi`: applying an initial `voi` by setting the windowWidth and windowCenter
|
||||
- `inverted`: inverting the viewport color (e.g., for PET images)
|
||||
|
||||
- `props settings`: Running commands after the first render; e.g., enabling the link tool
|
||||
|
||||
- `props settings`: Running commands after the first render; e.g., enabling the
|
||||
link tool
|
||||
|
||||
Examples of each settings are :
|
||||
|
||||
@@ -307,7 +162,7 @@ viewportSettings: [
|
||||
commandName: 'setToolActive',
|
||||
type: 'props',
|
||||
},
|
||||
]
|
||||
];
|
||||
```
|
||||
|
||||
and
|
||||
@@ -324,5 +179,5 @@ viewportSettings: [
|
||||
commandName: '',
|
||||
type: 'viewport',
|
||||
},
|
||||
]
|
||||
];
|
||||
```
|
||||
@@ -7,7 +7,7 @@ sidebar_label: Toolbar Service
|
||||
|
||||
## Overview
|
||||
|
||||
`ToolbarService` handles the toolbar section buttons, and what happens when a
|
||||
`ToolBarService` handles the toolbar section buttons, and what happens when a
|
||||
button is clicked by the user.
|
||||
|
||||
<div style={{padding:"56.25% 0 0 0", position:"relative"}}>
|
||||
@@ -19,12 +19,12 @@ button is clicked by the user.
|
||||
| Event | Description |
|
||||
| ----------------------- | ---------------------------------------------------------------------- |
|
||||
| TOOL_BAR_MODIFIED | Fires when a button is added/removed to the toolbar |
|
||||
| TOOL_BAR_STATE_MODIFIED | Fires when an interaction happens and ToolbarService state is modified |
|
||||
| TOOL_BAR_STATE_MODIFIED | Fires when an interaction happens and ToolBarService state is modified |
|
||||
|
||||
## API
|
||||
|
||||
- `recordInteraction(interaction)`: executes the provided interaction which is
|
||||
an object providing the following properties to the ToolbarService:
|
||||
an object providing the following properties to the ToolBarService:
|
||||
|
||||
- `interactionType`: can be `tool`, `toggle` and `action`. We will discuss
|
||||
more each type below.
|
||||
@@ -47,7 +47,7 @@ button is clicked by the user.
|
||||
|
||||
## State
|
||||
|
||||
ToolbarService has an internal state that gets updated per tool interaction and
|
||||
ToolBarService has an internal state that gets updated per tool interaction and
|
||||
tracks the active toolId, state of the buttons that have toggled state, and the
|
||||
group buttons and which tool in each group is active.
|
||||
|
||||
@@ -70,10 +70,10 @@ interaction object.
|
||||
|
||||
- `tool`: setting a tool to be active; e.g., measurement tools
|
||||
- `toggle`: toggling state of a tool; e.g., viewport link (sync)
|
||||
- `action`: performs a registered action outside of the ToolbarService; e.g.,
|
||||
- `action`: performs a registered action outside of the ToolBarService; e.g.,
|
||||
capture
|
||||
|
||||
A _simplified_ implementation of the ToolbarService is:
|
||||
A _simplified_ implementation of the ToolBarService is:
|
||||
|
||||
```js
|
||||
export default class ToolBarService {
|
||||
@@ -115,25 +115,32 @@ The simplest toolbarButtons definition has the following properties:
|
||||
|
||||
```js
|
||||
{
|
||||
id: 'Zoom',
|
||||
type: 'ohif.radioGroup',
|
||||
props: {
|
||||
type: 'tool',
|
||||
icon: 'tool-zoom',
|
||||
label: 'Zoom',
|
||||
commandOptions: { toolName: 'Zoom' },
|
||||
},
|
||||
},
|
||||
"id": "Zoom",
|
||||
"type": "ohif.radioGroup",
|
||||
"props": {
|
||||
"type": "tool",
|
||||
"icon": "tool-zoom",
|
||||
"label": "Zoom",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Zoom"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| property | description | values |
|
||||
| ---------------- | ----------------------------------------------------------------- | ------------------------------------------- |
|
||||
| `id` | Unique string identifier for the definition | \* |
|
||||
| `label` | User/display friendly to show in UI | \* |
|
||||
| `icon` | A string name for an icon supported by the consuming application. | \* |
|
||||
| `type` | Used to determine the button's behaviour | "tool", "toggle", "action" |
|
||||
| `commandName` | (optional) The command to run when the button is used. | Any command registered by a `CommandModule` |
|
||||
| `commandOptions` | (optional) Options to pass the target `commandName` | \* |
|
||||
| `icon` | A string name for an icon supported by the consuming application. | \* |
|
||||
| `label` | User/display friendly to show in UI | \* |
|
||||
| `commands` | (optional) The commands to run when the button is used. It include a commandName, commandOptions, and/or a context | Any command registered by a `CommandModule` |
|
||||
|
||||
There are three main types of toolbar buttons:
|
||||
|
||||
@@ -159,58 +166,91 @@ to create `MeasurementTools` nested button
|
||||
|
||||
```js title="modes/longitudinal/src/toolbarButtons.js"
|
||||
{
|
||||
id: 'MeasurementTools',
|
||||
type: 'ohif.splitButton',
|
||||
props: {
|
||||
groupId: 'MeasurementTools',
|
||||
isRadio: true,
|
||||
primary: {
|
||||
id: 'Length',
|
||||
icon: 'tool-length',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Length',
|
||||
}
|
||||
"id": "MeasurementTools",
|
||||
"type": "ohif.splitButton",
|
||||
"props": {
|
||||
"groupId": "MeasurementTools",
|
||||
"isRadio": true,
|
||||
"primary": {
|
||||
"id": "Length",
|
||||
"icon": "tool-length",
|
||||
"label": "Length",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Length"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRLength",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Length"
|
||||
},
|
||||
secondary: {
|
||||
icon: 'chevron-down',
|
||||
label: '',
|
||||
isActive: true,
|
||||
tooltip: 'More Measure Tools',
|
||||
"secondary": {
|
||||
"icon": "chevron-down",
|
||||
"label": "",
|
||||
"isActive": true,
|
||||
"tooltip": "More Measure Tools"
|
||||
},
|
||||
items: [
|
||||
// Length tool
|
||||
"items": [
|
||||
{
|
||||
id: 'Length',
|
||||
icon: 'tool-length',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Length',
|
||||
}
|
||||
"id": "Bidirectional",
|
||||
"icon": "tool-bidirectional",
|
||||
"label": "Bidirectional",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "Bidirectional"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRBidirectional",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Bidirectional Tool"
|
||||
},
|
||||
// Bidirectional tool
|
||||
{
|
||||
id: 'Bidirectional',
|
||||
icon: 'tool-bidirectional',
|
||||
label: 'Length',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'Bidirectional',
|
||||
}
|
||||
"id": "ArrowAnnotate",
|
||||
"icon": "tool-annotate",
|
||||
"label": "Annotation",
|
||||
"type": "tool",
|
||||
"commands": [
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "ArrowAnnotate"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
},
|
||||
{
|
||||
"commandName": "setToolActive",
|
||||
"commandOptions": {
|
||||
"toolName": "SRArrowAnnotate",
|
||||
"toolGroupId": "SRToolGroup"
|
||||
},
|
||||
"context": "CORNERSTONE"
|
||||
}
|
||||
],
|
||||
"tooltip": "Arrow Annotate"
|
||||
},
|
||||
// Ellipse tool
|
||||
{
|
||||
id: 'EllipticalRoi',
|
||||
icon: 'tool-elipse',
|
||||
label: 'Ellipse',
|
||||
type: 'tool',
|
||||
commandOptions: {
|
||||
toolName: 'EllipticalRoi',
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -17,7 +17,7 @@ We maintain the following non-ui Services:
|
||||
- [DicomMetadata Store](./../data/DicomMetadataStore.md)
|
||||
- [DisplaySet Service](./../data/DisplaySetService.md)
|
||||
- [Hanging Protocol Service](../data/HangingProtocolService.md)
|
||||
- [Toolbar Service](../data/ToolbarService.md)
|
||||
- [Toolbar Service](../data/ToolBarService.md)
|
||||
- [Measurement Service](../data/MeasurementService.md)
|
||||
|
||||
## Service Architecture
|
||||
|
||||
@@ -83,7 +83,7 @@ The following services is available in the `OHIF-v3`.
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="./data/ToolbarService">
|
||||
<a href="./data/ToolBarService">
|
||||
ToolBarService (NEW)
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@@ -2,27 +2,25 @@
|
||||
sidebar_position: 6
|
||||
sidebar_label: Viewport Grid Service
|
||||
---
|
||||
|
||||
# Viewport Grid Service
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
This is a new UI service, that handles the grid layout of the viewer.
|
||||
|
||||
|
||||
|
||||
|
||||
## Interface
|
||||
|
||||
For a more detailed look on the options and return values each of these methods
|
||||
is expected to support, [check out it's interface in `@ohif/core`][interface]
|
||||
|
||||
| API Member | Description |
|
||||
| -------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `setActiveViewportIndex(index)` | Sets the active viewport index in the app |
|
||||
| `getState()` | Gets the states of the viewport (see below) |
|
||||
| `setDisplaysetForViewport({ viewportIndex, displaySetInstanceUID })` | Sets displaySet for viewport based on displaySet Id |
|
||||
| `setLayout({numCols, numRows})` | Sets rows and columns |
|
||||
| `reset()` | Resets the default states |
|
||||
| API Member | Description |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `setActiveViewportIndex(index)` | Sets the active viewport index in the app |
|
||||
| `getState()` | Gets the states of the viewport (see below) |
|
||||
| `setDisplaySetsForViewport({ viewportIndex, displaySetInstanceUID })` | Sets displaySet for viewport based on displaySet Id |
|
||||
| `setLayout({numCols, numRows})` | Sets rows and columns |
|
||||
| `reset()` | Resets the default states |
|
||||
|
||||
## Implementations
|
||||
|
||||
@@ -32,7 +30,6 @@ is expected to support, [check out it's interface in `@ohif/core`][interface]
|
||||
|
||||
`*` - Denotes maintained by OHIF
|
||||
|
||||
|
||||
## State
|
||||
|
||||
```js
|
||||
@@ -49,5 +46,5 @@ const DEFAULT_STATE = {
|
||||
*/
|
||||
],
|
||||
activeViewportIndex: 0,
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -25,11 +25,12 @@ of our medical image web viewers from the start. The summary of changes includes
|
||||
- Our vision is that technical people focus of developing extensions which
|
||||
provides core functionalities, and experts to build modes by picking the
|
||||
appropriate functionalities from each extension.
|
||||
- Migrated all the `cornerstone-core` and `cornerstone-tools` usage to the newly released `Cornerstone3D`.
|
||||
|
||||
* UI has been completely redesigned with modularity and workflow modes in mind.
|
||||
* New UI components have been built with Tailwind CSS
|
||||
* Redux store has been removed from the viewer in favour of services backed by
|
||||
React's Context API
|
||||
React's Context **API**
|
||||
|
||||
Below, you can find the gap analysis between the `OHIF-v2` and `OHIF-v3`:
|
||||
|
||||
@@ -86,6 +87,10 @@ Below, you can find the gap analysis between the `OHIF-v2` and `OHIF-v3`:
|
||||
<td align="left">White-labelling: Easily replace the OHIF Logo with your logo</td>
|
||||
<td align="center">✅</td>
|
||||
<td align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">DICOM PDF support</td>
|
||||
<td align="center">✅</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">DICOM Whole-slide imaging viewport</td>
|
||||
@@ -97,11 +102,6 @@ Below, you can find the gap analysis between the `OHIF-v2` and `OHIF-v3`:
|
||||
<td align="center">🔜</td>
|
||||
<td align="center">Not Started</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">DICOM PDF support</td>
|
||||
<td align="center">🔜</td>
|
||||
<td align="center">Not Started</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">Displaying non-renderable DICOM as HTML</td>
|
||||
<td align="center">🔜</td>
|
||||
@@ -129,8 +129,8 @@ Below, you can find the gap analysis between the `OHIF-v2` and `OHIF-v3`:
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">VTK Extension + MIP / MPR layout</td>
|
||||
<td align="center">❌</td>
|
||||
<td align="center">Other plans that involves amazing news soon!</td>
|
||||
<td align="center">🔜</td>
|
||||
<td align="center">3D rendering and 3D annotation tools via Cornerstone3D</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">UMD Build (Embedded Viewer). </td>
|
||||
|
||||
@@ -11,6 +11,15 @@ and other resources that we have provided to the community in the past:
|
||||
|
||||
## 2022
|
||||
|
||||
### The Imaging Network Ontario - Remote
|
||||
|
||||
The Imaging Network Ontario (ImNO) is an annual symposium that brings together
|
||||
medical imaging researchers and scientists from across Canada to share
|
||||
knowledge, ideas, and experiences.
|
||||
[[Slides]](https://docs.google.com/presentation/d/18XZDon4-Sitc2a70V5sFyhyUVZI_mIgfXHGtdxhZMjE/edit?usp=sharing)
|
||||
[[Video]](https://vimeo.com/691134870/ad7d308a44)
|
||||
|
||||
|
||||
### [NA-MIC Project Week 36th 2022 - Remote](https://github.com/NA-MIC/ProjectWeek/blob/master/PW36_2022_Virtual/README.md)
|
||||
|
||||
The Project Week is a week-long hackathon of hands-on activity in which medical
|
||||
|
||||
@@ -7,26 +7,32 @@
|
||||
|
||||
const path = require('path');
|
||||
const versions = require('./versions.json');
|
||||
const VersionsArchived = require('./versionsArchived.json');
|
||||
|
||||
const ArchivedVersionsDropdownItems = Object.entries(VersionsArchived).splice(
|
||||
0,
|
||||
5
|
||||
);
|
||||
|
||||
// This probably only makes sense for the beta phase, temporary
|
||||
function getNextBetaVersionName() {
|
||||
const expectedPrefix = '';
|
||||
// function getNextBetaVersionName() {
|
||||
// const expectedPrefix = '';
|
||||
|
||||
const lastReleasedVersion = versions[0];
|
||||
if (!lastReleasedVersion.includes(expectedPrefix)) {
|
||||
throw new Error(
|
||||
'this code is only meant to be used during the 2.0 beta phase.'
|
||||
);
|
||||
}
|
||||
const version = parseInt(lastReleasedVersion.replace(expectedPrefix, ''), 10);
|
||||
return `${expectedPrefix}${version + 1}`;
|
||||
}
|
||||
// const lastReleasedVersion = versions[0];
|
||||
// if (!lastReleasedVersion.includes(expectedPrefix)) {
|
||||
// throw new Error(
|
||||
// 'this code is only meant to be used during the 2.0 beta phase.'
|
||||
// );
|
||||
// }
|
||||
// const version = parseInt(lastReleasedVersion.replace(expectedPrefix, ''), 10);
|
||||
// return `${expectedPrefix}${version + 1}`;
|
||||
// }
|
||||
|
||||
const allDocHomesPaths = [
|
||||
'/docs/',
|
||||
'/docs/next/',
|
||||
...versions.slice(1).map(version => `/docs/${version}/`),
|
||||
];
|
||||
// const allDocHomesPaths = [
|
||||
// '/docs/',
|
||||
// '/docs/next/',
|
||||
// ...versions.slice(1).map(version => `/docs/${version}/`),
|
||||
// ];
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -44,7 +50,7 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
// const isVersioningDisabled = !!process.env.DISABLE_VERSIONING || isI18nStaging;
|
||||
|
||||
/** @type {import('@docusaurus/types').DocusaurusConfig} */
|
||||
(module.exports = {
|
||||
module.exports = {
|
||||
title: 'OHIF',
|
||||
tagline: 'Open-source web-based medical imaging platform',
|
||||
organizationName: 'Open Health Imaging Foundation',
|
||||
@@ -56,11 +62,11 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
defaultLocale: 'en',
|
||||
locales: isDeployPreview
|
||||
? // Deploy preview: keep it fast!
|
||||
['en']
|
||||
: isI18nStaging
|
||||
? // Staging locales: https://docusaurus-i18n-staging.netlify.app/
|
||||
['en']
|
||||
: // Production locales
|
||||
: isI18nStaging
|
||||
? // Staging locales: https://docusaurus-i18n-staging.netlify.app/
|
||||
['en']
|
||||
: // Production locales
|
||||
['en'],
|
||||
},
|
||||
onBrokenLinks: 'warn',
|
||||
@@ -74,6 +80,12 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
plugins: [
|
||||
path.resolve(__dirname, './pluginOHIFWebpackConfig.js'),
|
||||
'plugin-image-zoom', // 3rd party plugin for image click to pop
|
||||
[
|
||||
'@docusaurus/plugin-google-gtag',
|
||||
{
|
||||
trackingID: 'UA-110573590-2',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@docusaurus/plugin-client-redirects',
|
||||
{
|
||||
@@ -191,8 +203,9 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
],
|
||||
presets: [
|
||||
[
|
||||
"@docusaurus/preset-classic",
|
||||
{
|
||||
'classic',
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
debug: true, // force debug plugin usage
|
||||
docs: {
|
||||
routeBasePath: '/',
|
||||
@@ -206,7 +219,7 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
// We want users to submit doc updates to the upstream/next version!
|
||||
// Otherwise we risk losing the update on the next release.
|
||||
const nextVersionDocsDirPath = 'docs';
|
||||
return `https://github.com/OHIF/Viewers/edit/master/website/${nextVersionDocsDirPath}/${docPath}`;
|
||||
return `https://github.com/OHIF/Viewers/edit/v3-stable/platform/docs/${docPath}`;
|
||||
},
|
||||
showLastUpdateAuthor: true,
|
||||
showLastUpdateTime: true,
|
||||
@@ -221,185 +234,206 @@ const isI18nStaging = process.env.I18N_STAGING === 'true';
|
||||
// : undefined,
|
||||
versions: {
|
||||
current: {
|
||||
label: 'Version 3.0 🚧',
|
||||
label: 'Version 3.1 - Cornerstone3D 🚧',
|
||||
},
|
||||
'2.0': {
|
||||
label: 'Version 2.0',
|
||||
label: 'Version 2.0 - Master branch',
|
||||
//path: `2.0`,
|
||||
},
|
||||
'1.0': {
|
||||
label: 'Version 1.0',
|
||||
//path: `1.0`,
|
||||
},
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
customCss: [require.resolve('./src/css/custom.css')],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
themeConfig: {
|
||||
liveCodeBlock: {
|
||||
playgroundPosition: 'bottom',
|
||||
},
|
||||
hideableSidebar: false,
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
disableSwitch: false,
|
||||
// respectPrefersColorScheme: true,
|
||||
},
|
||||
/*
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
liveCodeBlock: {
|
||||
playgroundPosition: 'bottom',
|
||||
},
|
||||
docs: {
|
||||
sidebar: {
|
||||
hideable: true,
|
||||
autoCollapseCategories: true,
|
||||
},
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: 'dark',
|
||||
disableSwitch: false,
|
||||
// respectPrefersColorScheme: true,
|
||||
},
|
||||
/*
|
||||
announcementBar: {
|
||||
id: 'supportus',
|
||||
content:
|
||||
'⭐️ If you like Docusaurus, give it a star on <a target="_blank" rel="noopener noreferrer" href="https://github.com/OHIF/Viewers">GitHub</a>! ⭐️',
|
||||
},
|
||||
*/
|
||||
prism: {
|
||||
theme: require('prism-react-renderer/themes/github'),
|
||||
darkTheme: require('prism-react-renderer/themes/dracula'),
|
||||
},
|
||||
// metadatas: [{name: 'twitter:card', content: 'summary'}],
|
||||
gtag: {
|
||||
trackingID: 'UA-110573590-2',
|
||||
},
|
||||
algolia: {
|
||||
apiKey: '1960250e38c7655d2bfe0ce8fdaed987',
|
||||
indexName: 'ohif',
|
||||
contextualSearch: true,
|
||||
},
|
||||
navbar: {
|
||||
hideOnScroll: false,
|
||||
logo: {
|
||||
alt: 'OHIF Logo',
|
||||
src: 'img/ohif-logo-light.svg',
|
||||
srcDark: 'img/ohif-logo.svg',
|
||||
prism: {
|
||||
theme: require('prism-react-renderer/themes/github'),
|
||||
darkTheme: require('prism-react-renderer/themes/dracula'),
|
||||
},
|
||||
items: [
|
||||
{
|
||||
to: 'https://ohif.org/get-started',
|
||||
label: 'Get Started',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
algolia: {
|
||||
appId: 'EFLT6YIHHZ',
|
||||
apiKey: 'c220dd24fe4f86248eea3b1238a1fb60',
|
||||
indexName: 'ohif',
|
||||
},
|
||||
navbar: {
|
||||
hideOnScroll: false,
|
||||
logo: {
|
||||
alt: 'OHIF Logo',
|
||||
src: 'img/ohif-logo-light.svg',
|
||||
srcDark: 'img/ohif-logo.svg',
|
||||
},
|
||||
{
|
||||
to: 'https://ohif.org/examples',
|
||||
label: 'Examples',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
position: 'left',
|
||||
to: '/',
|
||||
activeBaseRegex: '^(/next/|/)$',
|
||||
docId: 'Introduction',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
to: 'https://ohif.org/community',
|
||||
label: 'Community',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/help',
|
||||
//activeBaseRegex: '(^/help$)|(/help)',
|
||||
label: 'Help',
|
||||
position: 'right',
|
||||
},
|
||||
// { to: 'https://react.ohif.org/', label: 'UI Component Library', position: 'left' },
|
||||
// {to: 'showcase', label: 'Showcase', position: 'left'},
|
||||
// right
|
||||
{
|
||||
type: 'docsVersionDropdown',
|
||||
position: 'right',
|
||||
dropdownActiveClassDisabled: true,
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
to: '/platform/internationalization',
|
||||
label: 'Help Us Translate',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: 'https://github.com/OHIF/Viewers',
|
||||
position: 'right',
|
||||
className: 'header-github-link',
|
||||
'aria-label': 'GitHub Repository',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: ' ',
|
||||
items: [
|
||||
{
|
||||
// This doesn't show up on dev for some reason, but displays in build
|
||||
html: `
|
||||
items: [
|
||||
{
|
||||
to: 'https://ohif.org/get-started',
|
||||
label: 'Get Started',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: 'https://ohif.org/examples',
|
||||
label: 'Examples',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
position: 'left',
|
||||
to: '/',
|
||||
activeBaseRegex: '^(/next/|/)$',
|
||||
docId: 'Introduction',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
to: 'https://ohif.org/community',
|
||||
label: 'Community',
|
||||
target: '_self',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
to: '/help',
|
||||
//activeBaseRegex: '(^/help$)|(/help)',
|
||||
label: 'Help',
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
type: 'docsVersionDropdown',
|
||||
position: 'right',
|
||||
dropdownActiveClassDisabled: true,
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
type: 'html',
|
||||
value: '<hr class="dropdown-separator">',
|
||||
},
|
||||
{
|
||||
type: 'html',
|
||||
className: 'dropdown-archived-versions',
|
||||
value: '<b>Archived versions</b>',
|
||||
},
|
||||
...ArchivedVersionsDropdownItems.map(
|
||||
([versionName, versionUrl]) => ({
|
||||
label: versionName,
|
||||
href: versionUrl,
|
||||
})
|
||||
),
|
||||
{
|
||||
type: 'html',
|
||||
value: '<hr class="dropdown-separator">',
|
||||
},
|
||||
{
|
||||
to: '/versions',
|
||||
label: 'All versions',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'localeDropdown',
|
||||
position: 'right',
|
||||
dropdownItemsAfter: [
|
||||
{
|
||||
to: '/platform/internationalization',
|
||||
label: 'Help Us Translate',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
to: 'https://github.com/OHIF/Viewers',
|
||||
position: 'right',
|
||||
className: 'header-github-link',
|
||||
'aria-label': 'GitHub Repository',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: ' ',
|
||||
items: [
|
||||
{
|
||||
// This doesn't show up on dev for some reason, but displays in build
|
||||
html: `
|
||||
<a href="https://www.massgeneral.org/" target="_blank" rel="noreferrer noopener">
|
||||
<img src="/img/mgh-logo.png" id="mgh-logo" alt="MGH" />
|
||||
</a>
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Learn',
|
||||
items: [
|
||||
{
|
||||
label: 'Introduction',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: 'development/getting-started',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Discussion board',
|
||||
to: 'https://community.ohif.org/',
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
to: '/help',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: 'Donate',
|
||||
to: 'https://giving.massgeneral.org/ohif',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
to: 'https://github.com/OHIF/Viewers',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
to: 'https://twitter.com/OHIFviewer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logo: {
|
||||
alt: 'OHIF ',
|
||||
src: 'img/netlify-color-accent.svg',
|
||||
href: 'https://v3-demo.ohif.org/',
|
||||
},
|
||||
{
|
||||
title: 'Learn',
|
||||
items: [
|
||||
{
|
||||
label: 'Introduction',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
label: 'Installation',
|
||||
to: 'development/getting-started',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'Discussion board',
|
||||
to: 'https://community.ohif.org/',
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
to: '/help',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: 'Donate',
|
||||
to: 'https://giving.massgeneral.org/ohif',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
to: 'https://github.com/OHIF/Viewers',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
to: 'https://twitter.com/OHIFviewer',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
logo: {
|
||||
alt: 'OHIF ',
|
||||
src: 'img/netlify-color-accent.svg',
|
||||
href: 'https://v3-demo.ohif.org/',
|
||||
copyright: `OHIF is open source software released under the MIT license.`,
|
||||
},
|
||||
copyright: `OHIF is open source software released under the MIT license.`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
@@ -17,7 +17,7 @@
|
||||
# If 'production', `yarn install` does not install devDependencies
|
||||
NODE_ENV = "development"
|
||||
NODE_VERSION = "14.19.1"
|
||||
YARN_VERSION = "1.22.0"
|
||||
YARN_VERSION = "1.22.5"
|
||||
RUBY_VERSION = "2.6.2"
|
||||
YARN_FLAGS = "--no-ignore-optional --pure-lockfile"
|
||||
NETLIFY_USE_YARN = "true"
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"@docusaurus/preset-classic",
|
||||
"@docusaurus/preset-classic/**",
|
||||
"**/react-router-dom",
|
||||
"**/react-router"
|
||||
"**/react-router",
|
||||
"**/history"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
@@ -26,13 +27,14 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.0.0-beta.3",
|
||||
"@docusaurus/plugin-client-redirects": "2.0.0-beta.3",
|
||||
"@docusaurus/plugin-ideal-image": "2.0.0-beta.3",
|
||||
"@docusaurus/plugin-pwa": "2.0.0-beta.3",
|
||||
"@docusaurus/preset-classic": "2.0.0-beta.3",
|
||||
"@docusaurus/remark-plugin-npm2yarn": "2.0.0-beta.3",
|
||||
"@docusaurus/theme-live-codeblock": "2.0.0-beta.3",
|
||||
"@docusaurus/core": "2.0.0-beta.20",
|
||||
"@docusaurus/plugin-client-redirects": "2.0.0-beta.20",
|
||||
"@docusaurus/plugin-ideal-image": "2.0.0-beta.20",
|
||||
"@docusaurus/plugin-pwa": "2.0.0-beta.20",
|
||||
"@docusaurus/preset-classic": "2.0.0-beta.20",
|
||||
"@docusaurus/remark-plugin-npm2yarn": "2.0.0-beta.20",
|
||||
"@docusaurus/theme-live-codeblock": "2.0.0-beta.20",
|
||||
"@docusaurus/plugin-google-gtag": "2.0.0-beta.20",
|
||||
"@mdx-js/react": "^1.6.21",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"classnames": "^2.3.1",
|
||||
|
||||
@@ -256,3 +256,20 @@ html[data-theme='dark'] .docusaurus-highlight-code-line {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-separator {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.dropdown-archived-versions {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.code-block-error-line {
|
||||
background-color: #ff000020;
|
||||
display: block;
|
||||
margin: 0 calc(-1 * var(--ifm-pre-padding));
|
||||
padding: 0 var(--ifm-pre-padding);
|
||||
border-left: 3px solid #ff000080;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# Versions
|
||||
|
||||
As we are increasing the efforts to make the OHIF platform more robust and up-to-date
|
||||
with the latest software engineering practices, here we are listing the versions of
|
||||
the OHIF platform that we are currently supporting, and the versions that have been
|
||||
deprecated.
|
||||
|
||||
## Product Version vs Package Version
|
||||
|
||||
There are two types of versions that we need to consider here:
|
||||
|
||||
- Product Version: which is the end user visible version of the viewer
|
||||
- Package Version: which is the underlying libraries/packages of the platform on npm.
|
||||
|
||||
Currently we have three product versions:
|
||||
|
||||
- Version 1 (deprecated): Built with Meteor as a full stack application.
|
||||
- Version 2 (master branch): Front end image viewer built with React
|
||||
- Version 3 (alpha release): Re-architected Version 2.0 to allow for a more modular and customizable viewer.
|
||||
|
||||
As per package versioning, we follow semantic versioning which looks like *a.b.c* where:
|
||||
|
||||
- *a* is for major breaking changes
|
||||
- *b* is for new features
|
||||
- *c* is for patches/bug fixes
|
||||
|
||||
You can read more semantic versioning [here](https://semver.org/).
|
||||
|
||||
|
||||
## Maintained Product Versions
|
||||
|
||||
### Version 3.1 Cornerstone3D
|
||||
|
||||
OHIF Version 3.1 is the next major upcoming release of the OHIF platform. It uses
|
||||
the next generation of the cornerstone library, [Cornerstone 3D](https://github.com/cornerstonejs/cornerstone3D-beta).
|
||||
We are in the process of performing a parity check between this version and the `master`
|
||||
branch before we merge it into the master branch. You can read more about the
|
||||
roadmap timelines [here](https://ohif.org/roadmap/).
|
||||
|
||||
### Version 2.0: Master branch
|
||||
|
||||
Currently, the master branch of OHIF is on Product Version 2.0.
|
||||
|
||||
## Archived Versions
|
||||
|
||||
### Version 3.0 Cornerstone Legacy
|
||||
|
||||
Version 3.0 which uses [cornerstone-core](https://github.com/cornerstonejs/cornerstone) and
|
||||
[cornerstone-tools](https://github.com/cornerstonejs/cornerstoneTools) for rendering and
|
||||
manipulation/annotation of images.
|
||||
|
||||
### Version 1.0 Meteor
|
||||
|
||||
Deprecated in favor of Version 2.0. Built using full stack Meteor as a full stack application.
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
import React from 'react';
|
||||
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
|
||||
import Link from '@docusaurus/Link';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import { useActivePlugin, useDocVersionSuggestions } from '@theme/hooks/useDocs';
|
||||
import { useDocsPreferredVersion } from '@docusaurus/theme-common';
|
||||
|
||||
function UnreleasedVersionLabel({ siteTitle, versionMetadata }) {
|
||||
return (
|
||||
<Translate
|
||||
id="theme.docs.versions.unreleasedVersionLabel"
|
||||
description="The label used to tell the user that he's browsing an unreleased doc version"
|
||||
values={{
|
||||
siteTitle,
|
||||
versionLabel: <b>{versionMetadata.label}</b>,
|
||||
latestVersionLink: (
|
||||
<strong>
|
||||
<Link to={"/"}>
|
||||
<Translate
|
||||
id="theme.docs.versions.latestVersionLinkLabel"
|
||||
description="The label used for the latest version suggestion link label">
|
||||
docs
|
||||
</Translate>
|
||||
</Link>
|
||||
</strong>
|
||||
),
|
||||
}}>
|
||||
{
|
||||
'This is unreleased documentation for {siteTitle} {versionLabel} version. For up-to-date documentation, see OHIF v2.0 {latestVersionLink}.'
|
||||
}
|
||||
</Translate>
|
||||
);
|
||||
}
|
||||
|
||||
function UnmaintainedVersionLabel({ siteTitle, versionMetadata }) {
|
||||
return (
|
||||
<Translate
|
||||
id="theme.docs.versions.unmaintainedVersionLabel"
|
||||
description="The label used to tell the user that he's browsing an unmaintained doc version"
|
||||
values={{
|
||||
siteTitle,
|
||||
versionLabel: <b>{versionMetadata.label}</b>,
|
||||
}}>
|
||||
{
|
||||
'This is documentation for {siteTitle} {versionLabel}, which is no longer actively maintained.'
|
||||
}
|
||||
</Translate>
|
||||
);
|
||||
}
|
||||
|
||||
const BannerLabelComponents = {
|
||||
unreleased: UnreleasedVersionLabel,
|
||||
unmaintained: UnmaintainedVersionLabel,
|
||||
};
|
||||
|
||||
function BannerLabel(props) {
|
||||
const BannerLabelComponent =
|
||||
BannerLabelComponents[props.versionMetadata.banner];
|
||||
return <BannerLabelComponent {...props} />;
|
||||
}
|
||||
|
||||
function LatestVersionSuggestionLabel({ versionLabel, to, onClick }) {
|
||||
return (
|
||||
<Translate
|
||||
id="theme.docs.versions.latestVersionSuggestionLabel"
|
||||
description="The label used to tell the user to check the latest version"
|
||||
values={{
|
||||
versionLabel,
|
||||
latestVersionLink: (
|
||||
<b>
|
||||
<Link to={to} onClick={onClick}>
|
||||
<Translate
|
||||
id="theme.docs.versions.latestVersionLinkLabel"
|
||||
description="The label used for the latest version suggestion link label">
|
||||
latest version
|
||||
</Translate>
|
||||
</Link>
|
||||
</b>
|
||||
),
|
||||
}}>
|
||||
{
|
||||
'For up-to-date documentation, see the {latestVersionLink} ({versionLabel}).'
|
||||
}
|
||||
</Translate>
|
||||
);
|
||||
}
|
||||
|
||||
function DocVersionBannerEnabled({ versionMetadata }) {
|
||||
const {
|
||||
siteConfig: { title: siteTitle },
|
||||
} = useDocusaurusContext();
|
||||
const { pluginId } = useActivePlugin({
|
||||
failfast: true,
|
||||
});
|
||||
|
||||
const getVersionMainDoc = (version) =>
|
||||
version.docs.find((doc) => doc.id === version.mainDocId);
|
||||
|
||||
const { savePreferredVersionName } = useDocsPreferredVersion(pluginId);
|
||||
const {
|
||||
latestDocSuggestion,
|
||||
latestVersionSuggestion,
|
||||
} = useDocVersionSuggestions(pluginId); // try to link to same doc in latest version (not always possible)
|
||||
// fallback to main doc of latest version
|
||||
|
||||
const latestVersionSuggestedDoc =
|
||||
latestDocSuggestion ?? getVersionMainDoc(latestVersionSuggestion);
|
||||
return (
|
||||
<div className="alert alert--warning margin-bottom--md" role="alert">
|
||||
<div>
|
||||
<BannerLabel siteTitle={siteTitle} versionMetadata={versionMetadata} />
|
||||
</div>
|
||||
{/* <div className="margin-top--md">
|
||||
<LatestVersionSuggestionLabel
|
||||
versionLabel={latestVersionSuggestion.label}
|
||||
to={latestVersionSuggestedDoc.path}
|
||||
onClick={() => savePreferredVersionName(latestVersionSuggestion.name)}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocVersionBanner({ versionMetadata }) {
|
||||
if (versionMetadata.banner === 'none') {
|
||||
return <></>;
|
||||
} else {
|
||||
return <DocVersionBannerEnabled versionMetadata={versionMetadata} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default DocVersionBanner;
|
||||
@@ -7,8 +7,8 @@ module.exports = {
|
||||
content: [
|
||||
'./docs/**/*.jsx',
|
||||
'./docs/**/*.mdx',
|
||||
'./node_modules/@ohif/ui/src/**/*.{js,jsx,css}',
|
||||
'../../node_modules/@ohif/ui/src/**/*.{js,jsx,css}',
|
||||
'./node_modules/@ohif/ui/src/**/*.{js,jsx,ts,tsx,css}',
|
||||
'../../node_modules/@ohif/ui/src/**/*.{js,jsx,ts,tsx,css}',
|
||||
],
|
||||
},
|
||||
theme: {
|
||||
|
||||
@@ -98,12 +98,12 @@ window.config = {
|
||||
{ commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'] },
|
||||
{ commandName: 'invertViewport', label: 'Invert', keys: ['i'] },
|
||||
{
|
||||
commandName: 'flipViewportVertical',
|
||||
commandName: 'flipViewportHorizontal',
|
||||
label: 'Flip Horizontally',
|
||||
keys: ['h'],
|
||||
},
|
||||
{
|
||||
commandName: 'flipViewportHorizontal',
|
||||
commandName: 'flipViewportVertical',
|
||||
label: 'Flip Vertically',
|
||||
keys: ['v'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
id: Introduction
|
||||
slug: /
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
The [Open Health Imaging Foundation][ohif-org] (OHIF) Viewer is an open source,
|
||||
web-based, medical imaging platform. It aims to provide a core framework for
|
||||
building complex imaging applications.
|
||||
|
||||
Key features:
|
||||
|
||||
- Designed to load large radiology studies as quickly as possible. Retrieves
|
||||
metadata ahead of time and streams in imaging pixel data as needed.
|
||||
- Leverages [Cornerstone.js](https://cornerstonejs.org/) for decoding,
|
||||
rendering, and annotating medical images.
|
||||
- Works out-of-the-box with Image Archives that support [DICOMWeb][dicom-web].
|
||||
Offers a Data Source API for communicating with archives over proprietary API
|
||||
formats.
|
||||
- Provides a plugin framework for creating task-based workflow modes which can
|
||||
re-use core functionality.
|
||||
- Beautiful user interface (UI) designed with extensibility in mind. UI
|
||||
components available in a reusable component library built with React.js and
|
||||
Tailwind CSS
|
||||
|
||||

|
||||
|
||||
<div className="text--center">
|
||||
<div className="button-group">
|
||||
<a className='button button--primary' href="https://v3-demo.ohif.org/">Try the demo</a>
|
||||
<a className='button button--primary' href="./release-notes">Release Notes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## Where to next?
|
||||
|
||||
The Open Health Imaging Foundation intends to provide an imaging viewer
|
||||
framework which can be easily extended for specific uses. If you find yourself
|
||||
unable to extend the viewer for your purposes, please reach out via our [GitHub
|
||||
issues][gh-issues]. We are actively seeking feedback on ways to improve our
|
||||
integration and extension points.
|
||||
|
||||
Check out these helpful links:
|
||||
|
||||
- Ready to dive into some code? Check out our
|
||||
[Getting Started Guide](./development/getting-started.md).
|
||||
- We're an active, vibrant community.
|
||||
[Learn how you can be more involved.](./development/contributing.md)
|
||||
- Feeling lost? Read our [help page](/help).
|
||||
|
||||
## Citing OHIF
|
||||
|
||||
To cite the OHIF Viewer in an academic publication, please cite
|
||||
|
||||
> _Open Health Imaging Foundation Viewer: An Extensible Open-Source Framework
|
||||
> for Building Web-Based Imaging Applications to Support Cancer Research_
|
||||
>
|
||||
> Erik Ziegler, Trinity Urban, Danny Brown, James Petts, Steve D. Pieper, Rob
|
||||
> Lewis, Chris Hafey, and Gordon J. Harris _JCO Clinical Cancer Informatics_, no. 4 (2020), 336-345, DOI:
|
||||
> [10.1200/CCI.19.00131](https://www.doi.org/10.1200/CCI.19.00131)
|
||||
|
||||
This article is freely available on Pubmed Central: https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7259879/
|
||||
|
||||
|
||||
or, for Lesion Tracker of OHIF v1, please cite:
|
||||
|
||||
> _LesionTracker: Extensible Open-Source Zero-Footprint Web Viewer for Cancer
|
||||
> Imaging Research and Clinical Trials_
|
||||
>
|
||||
> Trinity Urban, Erik Ziegler, Rob Lewis, Chris Hafey, Cheryl Sadow, Annick D.
|
||||
> Van den Abbeele and Gordon J. Harris _Cancer Research_, November 1 2017 (77) (21) e119-e122 DOI:
|
||||
> [10.1158/0008-5472.CAN-17-0334](https://www.doi.org/10.1158/0008-5472.CAN-17-0334)
|
||||
|
||||
This article is freely available on Pubmed Central.
|
||||
https://pubmed.ncbi.nlm.nih.gov/29092955/
|
||||
|
||||
|
||||
**Note:** If you use or find this repository helpful, please take the time to
|
||||
star this repository on Github. This is an easy way for us to assess adoption,
|
||||
and it can help us obtain future funding for the project.
|
||||
|
||||
## License
|
||||
|
||||
MIT © [OHIF](https://github.com/OHIF)
|
||||
|
||||
|
||||
|
||||
<!--
|
||||
Links
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[ohif-org]: https://www.ohif.org
|
||||
[ohif-demo]: http://v3-demo.ohif.org/
|
||||
[dicom-web]: https://en.wikipedia.org/wiki/DICOMweb
|
||||
[gh-issues]: https://github.com/OHIF/Viewers/issues
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<defs id="defs3051">
|
||||
<style type="text/css" id="current-color-scheme">
|
||||
.ColorScheme-Text {
|
||||
color:#4d4d4d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path
|
||||
style="fill:currentColor;fill-opacity:1;stroke:none"
|
||||
d="M 7.5 2 A 4.5 4.5 0 0 0 3 6.5 A 4.5 4.5 0 0 0 3.0527344 7.15625 A 3 3 0 0 0 1 10 A 3 3 0 0 0 4 13 L 11.5 13 A 3.5 3.5 0 0 0 15 9.5 A 3.5 3.5 0 0 0 11.970703 6.0351562 A 4.5 4.5 0 0 0 7.5 2 z M 7.5 3 A 3.5 3.5 0 0 1 11 6.5 A 3.5 3.5 0 0 1 10.949219 7.0644531 A 2.5 2.5 0 0 1 11.5 7 A 2.5 2.5 0 0 1 14 9.5 A 2.5 2.5 0 0 1 11.5 12 L 4 12 A 2 2 0 0 1 2 10 A 2 2 0 0 1 4 8 A 2 2 0 0 1 4.3574219 8.0332031 A 3.5 3.5 0 0 1 4 6.5 A 3.5 3.5 0 0 1 7.5 3 z "
|
||||
id="path4139"
|
||||
class="ColorScheme-Text"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="npm" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 780 250" style="enable-background:new 0 0 780 250;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#C12127;}
|
||||
</style>
|
||||
<path class="st0" d="M240,250h100v-50h100V0H240V250z M340,50h50v100h-50V50z M480,0v200h100V50h50v150h50V50h50v150h50V0H480z
|
||||
M0,200h100V50h50v150h50V0H0V200z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 71 KiB |