feat: cornerstone3D stack and volume viewports (#2787)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

* fix handle the active tool when a new viewport is added

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

* feat: re-working measurement tracking mode with cornerstone3d (#2805)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: Add Measurement tracking mode with cs3D (#2789)

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

* feat: remove the need for separate mapper for SR hydration

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

* fix: integration cypress tests with cornerstone3D and add CINE tool (#2795)

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

* fix: drag and drop SR into SR viewport (#2803)

* update yarn lock

Co-authored-by: Erik <erik.sweed@gmail.com>

* update pathnames to match v3-stable

* update the e2e pathname

* fix: various bugs with regard to tracking workflow (#2811)

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

* feat: Add backward compatibility for SR hydration with legacy cornerstone

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

* feat: volume api and TMTV mode (#2817)

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

* feat: Add custom load strategy for volume viewports via HP

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

* fix: fusion to use pt in tmtv mode and measuremet mappings

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

* feat: use the new prefetch stack in the cs3d (#2820)

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

* fix: various bugs for tmtv mode thresholding and new icons (#2823)

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

* fix: Fix issues with CPU viewport flipping, including config files

* fix: bump cornerstone to fix magnify tool

* fix: Bump Cornerstone version to fix resetCamera issue in StackViewport

* ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys

* fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors

* feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838)

Also works with the compile time add that the existing cli uses, so that both build types work.
Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes.

* bump: dependency versions to fix hydration bugs (#2848)

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-07-27 12:39:04 -04:00
1 parent 86dcaf6293
commit bd1c38c00c
668 files changed
+41669 -13410

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",
-1
View File
@@ -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,
+2 -4
View File
@@ -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",
-240
View File
@@ -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 };
-15
View File
@@ -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;
+3 -15
View File
@@ -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)
);
+100 -56
View File
@@ -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',
+72 -14
View File
@@ -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 -3
View File
@@ -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,
+1 -1
View File
@@ -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;
+2
View File
@@ -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,
};
-136
View File
@@ -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;
+21
View File
@@ -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();
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Removes the data loader scheme from the imageId
*
* @param {string} imageId Image ID
* @returns {string} imageId without the data loader scheme
* @memberof Cache
*/
export default function imageIdToURI(imageId) {
const colonIndex = imageId.indexOf(':');
return imageId.substring(colonIndex + 1);
}
+13 -7
View File
@@ -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;
+4 -2
View File
@@ -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.
*
+5
View File
@@ -0,0 +1,5 @@
function _round(value, decimals) {
return Number(value.toFixed(decimals));
}
export default _round;
+13
View File
@@ -0,0 +1,13 @@
/**
* Returns the values as an array of javascript numbers
*
* @param val - The javascript object for the specified element in the metadata
* @returns {*}
*/
export default function toNumber(val) {
if (Array.isArray(val)) {
return val.map(v => (v !== undefined ? Number(v) : v));
} else {
return val !== undefined ? Number(val) : val;
}
}
+1 -1
View File
@@ -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>
);
}
```
![viewportModule](../../../assets/img/viewportModule.png)
### `@ohif/viewer`
+49 -42
View File
@@ -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';
+13 -10
View File
@@ -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
+2 -5
View File
@@ -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,
}
};
```
+8 -8
View File
@@ -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>
+9
View File
@@ -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
+215 -181
View File
@@ -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.`,
},
},
});
}),
};
+1 -1
View File
@@ -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"
+10 -8
View File
@@ -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",
+17
View File
@@ -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;
}
+54
View File
@@ -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;
+2 -2
View File
@@ -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
![OHIF Viewer Screenshot](./assets/img/OHIF-Viewer.png)
<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)
&nbsp;
<!--
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Loaded 100 of 668 files, more files were not shown because too many files have changed in this diff. Show more