From 680beb163f76aae84297308f7635899a27d3239e Mon Sep 17 00:00:00 2001 From: "Emanuel F. Oliveira" Date: Mon, 16 Jan 2017 12:47:29 -0200 Subject: [PATCH] Initial work on Nucleus parity merge --- Packages/ohif-core/main.js | 3 +- .../textMarkerDialogs/textMarkerDialogs.html | 99 +++ .../textMarkerDialogs/textMarkerDialogs.js | 124 ++++ .../textMarkerDialogs/textMarkerDialogs.styl | 147 +++++ Packages/ohif-viewerbase/client/index.js | 191 ++++++ .../client/lib/MetadataViews.js | 64 ++ .../client/lib/StackManager.js | 124 ++++ .../client/lib/classes/DisplaySet.js | 90 +++ .../client/lib/classes/ImageSet.js | 50 ++ .../client/lib/classes/LayoutManager.js | 589 ++++++++++++++++++ .../client/lib/classes/OHIFError.js | 14 + .../lib/classes/ResizeViewportManager.js | 153 +++++ .../client/lib/classes/TypeSafeCollection.js | 330 ++++++++++ .../lib/classes/metadata/InstanceMetadata.js | 173 +++++ .../client/lib/classes/metadata/Metadata.js | 40 ++ .../client/lib/classes/metadata/README.md | 128 ++++ .../lib/classes/metadata/SeriesMetadata.js | 109 ++++ .../lib/classes/metadata/StudyMetadata.js | 134 ++++ .../client/lib/classifyImageOrientation.js | 57 ++ .../client/lib/crosshairsSynchronizers.js | 4 + .../ohif-viewerbase/client/lib/dialogUtils.js | 72 +++ .../client/lib/getElementIfNotEmpty.js | 27 + .../client/lib/getFrameOfReferenceUID.js | 30 + .../client/lib/getSortedData.js | 251 ++++++++ .../client/lib/getStackDataIfNotEmpty.js | 22 + .../client/lib/helpers/index.js | 39 ++ .../ohif-viewerbase/client/lib/hotkeyUtils.js | 375 +++++++++++ .../client/lib/imageViewerViewportData.js | 7 + .../client/lib/panelNavigation.js | 53 ++ .../client/lib/seriesNavigation.js | 91 +++ .../client/lib/sortingManager.js | 13 + .../client/lib/thumbnailDragHandlers.js | 220 +++++++ .../lib/updateCrosshairsSynchronizer.js | 36 ++ .../client/lib/updateOrientationMarkers.js | 65 ++ .../client/lib/viewportOverlayUtils.js | 100 +++ .../client/lib/viewportUtils.js | 268 ++++++++ Packages/ohif-viewerbase/main.js | 39 ++ Packages/ohif-viewerbase/namespace.js | 23 + 38 files changed, 4353 insertions(+), 1 deletion(-) create mode 100644 Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.html create mode 100644 Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.js create mode 100644 Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.styl create mode 100644 Packages/ohif-viewerbase/client/index.js create mode 100644 Packages/ohif-viewerbase/client/lib/MetadataViews.js create mode 100644 Packages/ohif-viewerbase/client/lib/StackManager.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/DisplaySet.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/ImageSet.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/LayoutManager.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/OHIFError.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/ResizeViewportManager.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/TypeSafeCollection.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/metadata/InstanceMetadata.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/metadata/Metadata.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/metadata/README.md create mode 100644 Packages/ohif-viewerbase/client/lib/classes/metadata/SeriesMetadata.js create mode 100644 Packages/ohif-viewerbase/client/lib/classes/metadata/StudyMetadata.js create mode 100644 Packages/ohif-viewerbase/client/lib/classifyImageOrientation.js create mode 100644 Packages/ohif-viewerbase/client/lib/crosshairsSynchronizers.js create mode 100644 Packages/ohif-viewerbase/client/lib/dialogUtils.js create mode 100644 Packages/ohif-viewerbase/client/lib/getElementIfNotEmpty.js create mode 100644 Packages/ohif-viewerbase/client/lib/getFrameOfReferenceUID.js create mode 100644 Packages/ohif-viewerbase/client/lib/getSortedData.js create mode 100644 Packages/ohif-viewerbase/client/lib/getStackDataIfNotEmpty.js create mode 100644 Packages/ohif-viewerbase/client/lib/helpers/index.js create mode 100644 Packages/ohif-viewerbase/client/lib/hotkeyUtils.js create mode 100644 Packages/ohif-viewerbase/client/lib/imageViewerViewportData.js create mode 100644 Packages/ohif-viewerbase/client/lib/panelNavigation.js create mode 100644 Packages/ohif-viewerbase/client/lib/seriesNavigation.js create mode 100644 Packages/ohif-viewerbase/client/lib/sortingManager.js create mode 100644 Packages/ohif-viewerbase/client/lib/thumbnailDragHandlers.js create mode 100644 Packages/ohif-viewerbase/client/lib/updateCrosshairsSynchronizer.js create mode 100644 Packages/ohif-viewerbase/client/lib/updateOrientationMarkers.js create mode 100644 Packages/ohif-viewerbase/client/lib/viewportOverlayUtils.js create mode 100644 Packages/ohif-viewerbase/client/lib/viewportUtils.js create mode 100644 Packages/ohif-viewerbase/main.js create mode 100644 Packages/ohif-viewerbase/namespace.js diff --git a/Packages/ohif-core/main.js b/Packages/ohif-core/main.js index 92bc8d806..80ee5da3e 100644 --- a/Packages/ohif-core/main.js +++ b/Packages/ohif-core/main.js @@ -12,7 +12,8 @@ const OHIF = { }; // Expose the OHIF object to the client if it is on development mode -if (Meteor.isDevelopment && Meteor.isClient) { +// @TODO: remove this after applying namespace to this package +if (Meteor.isClient) { window.OHIF = OHIF; } diff --git a/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.html b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.html new file mode 100644 index 000000000..0bc49691a --- /dev/null +++ b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.html @@ -0,0 +1,99 @@ + diff --git a/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.js b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.js new file mode 100644 index 000000000..335418881 --- /dev/null +++ b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.js @@ -0,0 +1,124 @@ +/** + * @TODO: add this to OHIF's Viewers + */ + +import { Template } from 'meteor/templating'; +import { $ } from 'meteor/jquery'; + +import { toolManager } from '../../../lib/toolManager'; +import { viewportUtils } from '../../../lib/viewportUtils'; + +Template.textMarkerDialogs.events({ + 'change #startFrom'(e) { + const config = cornerstoneTools.textMarker.getConfiguration(); + config.current = $(e.target).val(); + //console.log("Changed starting point to: " + config.current); + }, + 'change #ascending'(e) { + const config = cornerstoneTools.textMarker.getConfiguration(); + config.ascending = $(e.target).is(':checked'); + + const currentIndex = config.markers.indexOf(config.current); + + config.current = config.markers[currentIndex]; + const nextMarker = config.current; + $('#startFrom').val(nextMarker).trigger('change'); + //console.log("Changed ascending to: " + config.ascending); + }, + 'click #clearLabels'() { + const element = viewportUtils.getActiveViewportElement(); + const toolType = 'textMarker'; + const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; + const toolState = toolStateManager.toolState; + + // We might want to make this a convenience function in cornerstoneTools + const stack = cornerstoneTools.getToolState(element, 'stack'); + if (stack && stack.data.length && stack.data[0].imageIds.length) { + const imageIds = stack.data[0].imageIds; + + // Clear the tool data for each image in the stack + imageIds.forEach( imageId => { + if(toolState.hasOwnProperty(imageId)) { + const toolData = toolState[imageId]; + if (toolData.hasOwnProperty(toolType)) { + delete toolData[toolType]; + } + } + }); + } + + cornerstone.updateImage(element); + }, + 'click .closeTextMarkerDialogs'() { + const defaultTool = toolManager.getDefaultTool(); + toolManager.setActiveTool(defaultTool); + document.getElementById('textMarkerOptionsDialog').close(); + $('#spine').removeClass('active'); + $('#' + defaultTool).addClass('active'); + } + +}); + +Template.textMarkerDialogs.onRendered(function() { + const optionsDialog = $('#textMarkerOptionsDialog'); + optionsDialog.draggable(); + dialogPolyfill.registerDialog(optionsDialog.get(0)); + + const relabelDialog = $('#textMarkerRelabelDialog'); + relabelDialog.draggable(); + dialogPolyfill.registerDialog(relabelDialog.get(0)); + + $(document).on('click', event => { + if (!$(event.target).closest('.select2-wrapper').length) { + setTimeout(() => { + $('#startFrom, .relabelSelect').select2('close'); + }, 200); + } + }); + + $(document).on('touchmove', event => { + if (!$(event.target).closest('.select2-container').length) { + setTimeout(() => { + $('#startFrom, .relabelSelect').select2('close'); + }, 200); + } + }); + + $(() => { + + FastClick.attach(document.body); + + const $customSelects = $('#startFrom, .relabelSelect') + + $customSelects.select2({ + /** + * Adds needsclick class to all DOM elements in the Select2 results list + * so they can be accessible on iOS mobile when FastClick is initiated too. + */ + templateResult(result, container) { + if (!result.id) { + return result.text; + } + container.className += ' needsclick'; + return result.text; + }, + placeholder: 'C1', + minimumResultsForSearch: -1, + theme: 'viewerDropdown' + }); + + /** + * Additional to tweaking the templateResult option in Select2, + * add needsclick class to all DOM elements in the Select2 container, + * so they can be accessible on iOS mobile when FastClick is initiated too. + * + * More info about needsclick: + * https://github.com/ftlabs/fastclick#ignore-certain-elements-with-needsclick + * + */ + $customSelects.each( (index, el) =>{ + $(el).data('select2').$container.find('*').addClass('needsclick'); + }); + + }); +}); \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.styl b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.styl new file mode 100644 index 000000000..d274186d0 --- /dev/null +++ b/Packages/ohif-viewerbase/client/components/viewer/textMarkerDialogs/textMarkerDialogs.styl @@ -0,0 +1,147 @@ +.textMarkerDialog + z-index: 1000 + width: 260px + position: absolute + top: auto + left: auto + bottom: 3px + right: 3px + margin: auto + padding:.5em + background-image: linear-gradient(#2E608D, #23557F) + box-sizing: border-box + border: 1px black solid + border-radius: 5px + height: 81px + max-height: 81px + h5 + color: #D1EDFB; + font-size: 14px; + line-height: 22px; + font-weight: 600; + cursor:default + .handle + width: 40px; + float: left; + height: 22px; + display: block; + margin-right: 10px; + position: relative; + margin-top: 1px; + margin-left: 1px; + #startFrom + margin-right:10px + #clearLabels + margin-left:10px + float:right + .optionBox + color: #d1edfb + margin-bottom: 5px + float: left + span.subLabel + width: 50px + display: block + float: left + line-height: 30px + > label + color: #d1edfb + float: left + min-width: 40px + line-height: 24px + font-size: 12px + font-weight: 600 + .closeTextMarkerDialogs + position: absolute + top: 5px + right: 4px + padding: 5px + cursor: pointer + svg + width: 19px + height: 19px + fill: #d1edfb + &:hover + svg + fill: #FFF + .dialog.arrow + left: 50% + margin-left: -11px + border-top-width: 0 + border-bottom-color: #999999 + border-bottom-color: rgba(0, 0, 0, 0.25) + border-width: 11px + bottom: -10px + position: absolute + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid #23557f; + &:after + content: " " + top: 1px + margin-left: -10px + border-top-width: 0 + border-bottom-color: #23557f + border-width: 10px + +#textMarkerRelabelDialog + margin: 0 + .relabelOptions + padding: 15px 0px 0px 0px + .relabelSelect + margin-left: 5px + +#textMarkerOptionsDialog + .optionsDiv + padding-top: 10px + + + +.relabelButtons + text-align:right + +button.viewerBtn + background-color: #5D9ACE; + border: 1px solid #6EAEE4; + font-size: 12px !important; + color:#FFF + line-height: 21px; + &:hover + color:#FFF + +button.btn-secondary + background-color: #CFE3F5; + border: 1px solid #E5F3FF; + font-size: 12px !important; + color:#0D416D + &:hover + color:#0D416D + +.iconSwitch + margin:0px 5px + .btn + span + display: none + .on + display: none + .off + display: block + &.btn-link, &.active, &:active, &:hover, &:focus, &.active:focus + background-color: #225682; + border: 1px solid #7AB0DE; + box-shadow: none; + padding: 1px 7px; + border-radius: 4px; + color: #d1edfb; + box-shadow: none; + outline: none; + &:hover + color:#fff + &.active + .on + display:block + .off + display: none + + diff --git a/Packages/ohif-viewerbase/client/index.js b/Packages/ohif-viewerbase/client/index.js new file mode 100644 index 000000000..2cdbf046f --- /dev/null +++ b/Packages/ohif-viewerbase/client/index.js @@ -0,0 +1,191 @@ +import { Viewerbase } from '../namespace'; + +/** + * Exported Functions + */ + +// getElementIfNotEmpty +import { getElementIfNotEmpty } from './lib/getElementIfNotEmpty'; +Viewerbase.getElementIfNotEmpty = getElementIfNotEmpty; + +// getStackDataIfNotEmpty +import { getStackDataIfNotEmpty } from './lib/getStackDataIfNotEmpty'; +Viewerbase.getStackDataIfNotEmpty = getStackDataIfNotEmpty; + +// switchToImageRelative +import { switchToImageRelative } from './lib/switchToImageRelative'; +Viewerbase.switchToImageRelative = switchToImageRelative; + +// switchToImageByIndex +import { switchToImageByIndex } from './lib/switchToImageByIndex'; +Viewerbase.switchToImageByIndex = switchToImageByIndex; + +// getFrameOfReferenceUID +import { getFrameOfReferenceUID } from './lib/getFrameOfReferenceUID'; +Viewerbase.getFrameOfReferenceUID = getFrameOfReferenceUID; + +// updateCrosshairsSynchronizer +import { updateCrosshairsSynchronizer } from './lib/updateCrosshairsSynchronizer'; +Viewerbase.updateCrosshairsSynchronizer = updateCrosshairsSynchronizer; + +// getImageId +import { getImageId } from './lib/getImageId'; +Viewerbase.getImageId = getImageId; + +// setActiveViewport +import { setActiveViewport } from './lib/setActiveViewport'; +Viewerbase.setActiveViewport = setActiveViewport; + +// classifyImageOrientation +import { classifyImageOrientation } from './lib/classifyImageOrientation'; +Viewerbase.classifyImageOrientation = classifyImageOrientation; + +// setFocusToActiveViewport +import { setFocusToActiveViewport } from './lib/setFocusToActiveViewport'; +Viewerbase.setFocusToActiveViewport = setFocusToActiveViewport; + +// getWADORSImageId +import { getWADORSImageId } from './lib/getWADORSImageId'; +Viewerbase.getWADORSImageId = getWADORSImageId; + +// updateAllViewports +import { updateAllViewports } from './lib/updateAllViewports'; +Viewerbase.updateAllViewports = updateAllViewports; + +// sortStudy +import { sortStudy } from './lib/sortStudy'; +Viewerbase.sortStudy = sortStudy; + +// updateOrientationMarkers +import { updateOrientationMarkers } from './lib/updateOrientationMarkers'; +Viewerbase.updateOrientationMarkers = updateOrientationMarkers; + +// isImage +import { isImage } from './lib/isImage'; +Viewerbase.isImage = isImage; + +// getInstanceClassDefaultViewport, setInstanceClassDefaultViewportFunction +import { getInstanceClassDefaultViewport, setInstanceClassDefaultViewportFunction } from './lib/instanceClassSpecificViewport'; +Viewerbase.getInstanceClassDefaultViewport = getInstanceClassDefaultViewport; +Viewerbase.setInstanceClassDefaultViewportFunction = setInstanceClassDefaultViewportFunction; + +// setMammogramViewportAlignment +import { setMammogramViewportAlignment } from './lib/setMammogramViewportAlignment'; +Viewerbase.setMammogramViewportAlignment = setMammogramViewportAlignment; + +// addMetaData, addSpecificMetadata, updateMetaData +import { addMetaData, addSpecificMetadata, updateMetaData } from './lib/metaDataProvider'; +Viewerbase.addMetaData = addMetaData; +Viewerbase.addSpecificMetadata = addSpecificMetadata; +Viewerbase.updateMetaData = updateMetaData; + +/** + * Exported Namespaces (sub-namespaces) + */ + +// imageViewerViewportData.* +import { imageViewerViewportData } from './lib/imageViewerViewportData'; +Viewerbase.imageViewerViewportData = imageViewerViewportData; + +// panelNavigation.* +import { panelNavigation } from './lib/panelNavigation'; +Viewerbase.panelNavigation = panelNavigation; + +// seriesNavigation.* +import { seriesNavigation } from './lib/seriesNavigation'; +Viewerbase.seriesNavigation = seriesNavigation; + +// WLPresets.* +import { WLPresets } from './lib/WLPresets'; +Viewerbase.wlPresets = WLPresets; + +// hotkeyUtils.* +import { hotkeyUtils } from './lib/hotkeyUtils'; +Viewerbase.hotkeyUtils = hotkeyUtils; + +// viewportOverlayUtils.* +import { viewportOverlayUtils } from './lib/viewportOverlayUtils'; +Viewerbase.viewportOverlayUtils = viewportOverlayUtils; + +// viewportUtils.* +import { viewportUtils } from './lib/viewportUtils'; +Viewerbase.viewportUtils = viewportUtils; + +// thumbnailDragHandlers.* +import { thumbnailDragHandlers } from './lib/thumbnailDragHandlers'; +Viewerbase.thumbnailDragHandlers = thumbnailDragHandlers; + +// dialogUtils.* +import { dialogUtils } from './lib/dialogUtils'; +Viewerbase.dialogUtils = dialogUtils; + +// unloadHandlers.* +import { unloadHandlers } from './lib/unloadHandlers'; +Viewerbase.unloadHandlers = unloadHandlers; + +// sortingManager.* +import { sortingManager } from './lib/sortingManager'; +Viewerbase.sortingManager = sortingManager; + +// crosshairsSynchronizers.* +import { crosshairsSynchronizers } from './lib/crosshairsSynchronizers'; +Viewerbase.crosshairsSynchronizers = crosshairsSynchronizers; + +/** + * Exported Singletons + */ + +// StackManager as "stackManager" (since it's a plain object instance, the exported name starts with a lowercase letter) +import { StackManager } from './lib/StackManager'; +Viewerbase.stackManager = StackManager; + +// toolManager +import { toolManager } from './lib/toolManager'; +Viewerbase.toolManager = toolManager; + +/** + * Exported Helpers + */ + +import { helpers } from './lib/helpers/'; +Viewerbase.helpers = helpers; + +/** + * Exported Collections + */ + +// sopClassDictionary +import { sopClassDictionary } from './lib/sopClassDictionary'; +Viewerbase.sopClassDictionary = sopClassDictionary; + +/** + * Exported Classes + */ + +// ImageSet +import { ImageSet } from './lib/classes/ImageSet'; +Viewerbase.ImageSet = ImageSet; + +// ResizeViewportManager +import { ResizeViewportManager } from './lib/classes/ResizeViewportManager'; +Viewerbase.ResizeViewportManager = ResizeViewportManager; + +// StudyMetadata, SeriesMetadata, InstanceMetadata +import { StudyMetadata } from './lib/classes/metadata/StudyMetadata'; +import { SeriesMetadata } from './lib/classes/metadata/SeriesMetadata'; +import { InstanceMetadata } from './lib/classes/metadata/InstanceMetadata'; +Viewerbase.metadata = { StudyMetadata, SeriesMetadata, InstanceMetadata }; + +// TypeSafeCollection +import { TypeSafeCollection } from './lib/classes/TypeSafeCollection'; +Viewerbase.TypeSafeCollection = TypeSafeCollection; + +// OHIFError +import { OHIFError } from './lib/classes/OHIFError'; +Viewerbase.OHIFError = OHIFError; + +/** + * Imports for Side Effects Only + */ + +import './lib/debugReactivity'; diff --git a/Packages/ohif-viewerbase/client/lib/MetadataViews.js b/Packages/ohif-viewerbase/client/lib/MetadataViews.js new file mode 100644 index 000000000..d669c850b --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/MetadataViews.js @@ -0,0 +1,64 @@ +// Default Configuration to handle Metadata from WADO or DIMSE, +// currently retrieved through Meteor server +let configuration = { + // These are examples, they need to actually check the properties of the Objects + getSeries: (study, seriesNumOrUid) => { + // Use Array to find the appropriate Series + return study.seriesList.find(series => series['seriesNumber'] === value); + }, + + getInstance: (series, instanceNumOrUid) => { + return series.instances.find(instance => instance['instanceNumber'] === instanceNumOrUid) + } + + getInstanceTagRaw: (instance, tag) => { + // (this won't actually work yet) + return instance[tag]; + } + + getInstanceTagNum: (instance, tag) => { + // (this won't actually work yet) + return parseFloat(instance[tag]); + } +}; + +// Example Nucleus configuration +// +configuration = { + getSeries: (study, seriesNumOrUid) => study.studyView.series(seriesNumOrInstanceUid), + getInstance: (series, instanceNumOrUid) => series.seriesView.instance(instanceNumOrUid), + getInstanceTagRaw: (instance, tag) => instance.instanceView.raw(tag), + getInstanceTagNum: (instance, tag) => instance.instanceView.num(tag), + getCustom(instance, custom) => instance.instanceView[custom] +} + +class StudyMetadataView { + series(tag, value) { + // Retrieves Series by Series Number or SeriesInstanceUid + return configuration.getSeries(study, tag, value); + } +} + +class SeriesMetadataView { + instances(tag, value) { + // Retrieves Instances by Instances Number or SOPInstanceUid + return configuration.getInstance(series, tag, value); + } +} + +class InstanceMetadataView { + /* Retrieves raw value of tag from instance */ + raw(tag) { + return configuration.getInstanceTagRaw(instance, tag); + } + + /* Retrieves raw value of tag from instance */ + num(tag) { + return configuration.getInstanceTagNum(instance, tag); + } + + custom(custom) { + return configuration.getCustom(instance, custom) + } + // etc.. Date, DateTime, ...? Sequence... +} \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/StackManager.js b/Packages/ohif-viewerbase/client/lib/StackManager.js new file mode 100644 index 000000000..cca4d9706 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/StackManager.js @@ -0,0 +1,124 @@ +import { OHIF } from 'meteor/ohif:core'; +import { getImageId } from './getImageId'; +import { addMetaData } from './metaDataProvider'; +import { OHIFError } from './classes/OHIFError'; + +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} study Study object + * @param {Object} displaySet The set of images to make the stack from + * @return {Array} Array with image IDs + */ +function createAndAddStack(stackMap, study, displaySet) { + const numImages = displaySet.images.length; + const imageIds = []; + let imageId; + + displaySet.images.forEach((image, imageIndex) => { + const metaData = { + instance: image, + series: displaySet, // TODO: Check this + study: study, + numImages: numImages, + imageIndex: imageIndex + 1 + }; + + const numFrames = image.numFrames; + if (numFrames > 1) { + OHIF.log.info('Multiframe image detected'); + for (let i = 0; i < numFrames; i++) { + metaData.frame = i; + imageId = getImageId(image, i); + imageIds.push(imageId); + addMetaData(imageId, metaData); + } + } + else { + imageId = getImageId(image); + imageIds.push(imageId); + addMetaData(imageId, metaData); + } + }); + + return imageIds; +} + +configuration = { + createAndAddStack: 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. + * clearStacks and makeAndAddStack should not used outside of loadStudy and removeStudies. + */ +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 study The study who's metadata will be added + * @param displaySet The set of images to make the stack from + * @return {Array} Array with image IDs + */ + makeAndAddStack(study, displaySet) { + return configuration.createAndAddStack(stackMap, study, displaySet, 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]; + }, + /** + * 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 OHIFError('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 }; diff --git a/Packages/ohif-viewerbase/client/lib/classes/DisplaySet.js b/Packages/ohif-viewerbase/client/lib/classes/DisplaySet.js new file mode 100644 index 000000000..0a8d05ade --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/DisplaySet.js @@ -0,0 +1,90 @@ +import { Random } from 'meteor/random'; +import { Metadata } from './metadata/Metadata'; +import { InstanceMetadata } from './metadata/InstanceMetadata'; + +export class DisplaySet { + + constructor(instances) { + this._uid = Random.id(); + this._attributes = {}; + this._instances = []; + + if (instances instanceof Array) { + instances.forEach( instance => this.addInstance(instance)); + } + } + + getUID() { + return this._uid; + } + + /** + * Retrieve the number of instances within the current display set. + * @returns {number} The number of instances in the current display set. + */ + getInstanceCount() { + return this._instances.length; + } + + /** + * Find an instance by index. + * @param {number} index An integer representing a list index. + * @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise. + */ + getInstanceByIndex(index) { + let found; // undefined by default... + if (Metadata.isValidIndex(index)) { + found = this._instances[index]; + } + return found; + } + + /** + * Invokes the supplied callback for each instance in the current display set passing + * two arguments: instance (InstanceMetadata) and index (the integer index of the instance within the current display set) + * @param {function} callback The callback function which will be invoked for each instance in the display set. + * @returns {undefined} Nothing is returned. + */ + forEachInstance(callback) { + if (Metadata.isValidCallback(callback)) { + this._instances.forEach((instance, index) => { + callback.call(null, instance, index); + }); + } + } + + /** + * Append an instance to the current series. + * @param {InstanceMetadata} instance The instance to be added to the current series. + * @returns {boolean} Returns true on success, false otherwise. + */ + addInstance(instance) { + let result = false; + if (instance instanceof InstanceMetadata) { + this._instances.push(instance); + result = true; + } + return result; + } + + setAttribute(attribute, value) { + this._attributes[attribute] = value; + } + + getAttribute(attribute) { + return this._attributes[attribute]; + } + + setAttributes(attributes) { + if (typeof attributes === 'object' && attributes !== null) { + const _attributes = this._attributes; + const hasOwn = Object.prototype.hasOwnProperty; + for (let attribute in attributes) { + if (hasOwn.call(attributes, attribute)) { + _attributes[attribute] = attributes[attribute]; + } + } + } + } + +} \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/classes/ImageSet.js b/Packages/ohif-viewerbase/client/lib/classes/ImageSet.js new file mode 100644 index 000000000..86cab9da8 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/ImageSet.js @@ -0,0 +1,50 @@ +import { Random } from 'meteor/random'; + +/** + * This class defines an ImageSet object which will be used across the viewer. This object represents + * a list of images that are associated by any arbitrary criteria being thus content agnostic. Besides the + * main attributes (images and uid) it allows additional attributes to be appended to it (currently + * indiscriminately, but this should be changed). + */ +export class ImageSet { + + constructor(images) { + + if (Array.isArray(images) !== true) { + throw new TypeError('ImageSet expects an array of images...'); + } + + // Main ImageSet attributes + this.images = images; // Array of images + this.uid = Random.id(); // Unique ID of the instance + + } + + setAttribute(attribute, value) { + this[attribute] = value; + } + + getAttribute(attribute) { + return this[attribute]; + } + + setAttributes(attributes) { + if (typeof attributes === 'object' && attributes !== null) { + const imageSet = this, hasOwn = Object.prototype.hasOwnProperty; + for (let attribute in attributes) { + if (hasOwn.call(attributes, attribute)) { + imageSet[attribute] = attributes[attribute]; + } + } + } + } + + getImage(index) { + return this.images[index]; + } + + sortBy(sortingCallback) { + return this.images.sort(sortingCallback); + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/LayoutManager.js b/Packages/ohif-viewerbase/client/lib/classes/LayoutManager.js new file mode 100644 index 000000000..deab056f4 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/LayoutManager.js @@ -0,0 +1,589 @@ +import { Blaze } from 'meteor/blaze'; +import { Session } from 'meteor/session'; +import { Tracker } from 'meteor/tracker'; +import { Template } from 'meteor/templating'; +import { Random } from 'meteor/random'; +import { _ } from 'meteor/underscore'; +import { $ } from 'meteor/jquery'; + +import { OHIF } from 'meteor/ohif:core'; + +// Displays Series in Viewports given a Protocol and list of Studies +export class LayoutManager { + constructor(parentNode, studies) { + OHIF.log.info('LayoutManager constructor'); + + this.parentNode = parentNode; + this.studies = studies; + this.viewportData = []; + this.layoutTemplateName = 'gridLayout'; + this.layoutProps = { + rows: 1, + columns: 1 + }; + + this.isZoomed = false; + + const updateSessionFn = () => Tracker.afterFlush(() => Session.set('LayoutManagerUpdated', Random.id())); + this.updateSession = _.throttle(updateSessionFn, 300); + } + + getNumberOfViewports() { + return this.layoutProps.rows * this.layoutProps.columns; + } + + setDefaultViewportData() { + OHIF.log.info('LayoutManager setDefaultViewportData'); + + const self = this; + + // Get the number of vieports to be rendered + const viewportsAmount = this.getNumberOfViewports(); + + // Store the old viewport data and reset the current + const oldViewportData = self.viewportData; + + // Get the studies and display sets sequence map + const sequenceMap = this.getDisplaySetSequenceMap(); + + // Check if the display sets are sequenced + const isSequenced = this.isDisplaySetsSequenced(sequenceMap); + + // Define the current viewport index and the viewport data array + let currentViewportIndex = 0; + if (viewportsAmount > oldViewportData.length && oldViewportData.length && isSequenced) { + // Keep the displayed display sets + self.viewportData = oldViewportData; + currentViewportIndex = oldViewportData.length; + } + else if (viewportsAmount <= oldViewportData.length) { + // Reduce the original displayed display sets + self.viewportData = oldViewportData.slice(0, viewportsAmount); + return; + } + else { + // Reset all display sets + self.viewportData = []; + } + + // Get all the display sets for the viewer studies + let displaySets = []; + this.studies.forEach(study => { + study.series.forEach(displaySet => { + displaySet.images.length && displaySets.push(displaySet); + }); + }); + + // Get the display sets that will be appended to the current ones + let appendix; + const currentLength = self.viewportData.length; + if (currentLength) { + // TODO: isolate displaySets array by study (maybe a map?) + const beginIndex = sequenceMap.values().next().value[0].displaySetIndex + currentLength; + const endIndex = beginIndex + (viewportsAmount - currentLength); + appendix = displaySets.slice(beginIndex, endIndex); + } + else { + // Get available display sets from the first to the grid size + appendix = displaySets.slice(0, viewportsAmount); + } + + // Generate the additional data based on the appendix + const additionalData = []; + appendix.forEach((displaySet, index) => { + additionalData.push({ + viewportIndex: currentViewportIndex + index, + studyInstanceUid: displaySet.studyInstanceUid, + seriesInstanceUid: displaySet.seriesInstanceUid, + displaySetInstanceUid: displaySet.displaySetInstanceUid, + sopInstanceUid: displaySet.images[0].sopInstanceUid + }); + }); + + // Append the additional data with the viewport data + self.viewportData = self.viewportData.concat(additionalData); + + // Push empty objects if the amount is lesser than the grid size + while (self.viewportData.length < viewportsAmount) { + self.viewportData.push({}); + } + } + + updateViewports() { + OHIF.log.info('LayoutManager updateViewports'); + + if (!this.viewportData || + !this.viewportData.length || + this.viewportData.length !== this.getNumberOfViewports()) { + this.setDefaultViewportData(); + } + + // imageViewerViewports occasionally needs relevant layout data in order to set + // the element style of the viewport in question + const layoutProps = this.layoutProps; + const data = $.extend({ + viewportData: [] + }, layoutProps); + + this.viewportData.forEach( viewportData => { + const viewportDataAndLayoutProps = $.extend(viewportData, layoutProps); + data.viewportData.push(viewportDataAndLayoutProps); + }); + + const layoutTemplate = Template[this.layoutTemplateName]; + + $(this.parentNode).html(''); + Blaze.renderWithData(layoutTemplate, data, this.parentNode); + + this.updateSession(); + + this.isZoomed = false; + } + + /** + * This function destroys and re-renders the imageViewerViewport template. + * It uses the data provided to load a new display set into the produced viewport. + * + * @param viewportIndex + * @param data + */ + rerenderViewportWithNewDisplaySet(viewportIndex, data) { + OHIF.log.info(`LayoutManager rerenderViewportWithNewDisplaySet: ${viewportIndex}`); + + // The parent container is identified because it is later removed from the DOM + const element = $('.imageViewerViewport').eq(viewportIndex); + const container = element.parents('.viewportContainer').get(0); + + // Record the current viewportIndex so this can be passed into the re-rendering call + data.viewportIndex = viewportIndex; + + // Update the dictionary of loaded displaySet for the specified viewport + this.viewportData[viewportIndex] = { + viewportIndex: viewportIndex, + displaySetInstanceUid: data.displaySetInstanceUid, + seriesInstanceUid: data.seriesInstanceUid, + studyInstanceUid: data.studyInstanceUid, + renderedCallback: data.renderedCallback, + currentImageIdIndex: data.currentImageIdIndex || 0 + }; + + // Remove the hover styling + element.find('canvas').not('.magnifyTool').removeClass('faded'); + + // Remove the whole template, add in the new one + const viewportContainer = element.parents('.removable'); + + const newViewportContainer = document.createElement('div'); + newViewportContainer.className = 'removable'; + + // Remove the parent element of the template + // This is a workaround since otherwise Blaze UI onDestroyed doesn't fire + viewportContainer.remove(); + + container.appendChild(newViewportContainer); + + // Render and insert the template + Blaze.renderWithData(Template.imageViewerViewport, data, newViewportContainer); + + this.updateSession(); + } + + enlargeViewport(viewportIndex) { + OHIF.log.info(`LayoutManager enlargeViewport: ${viewportIndex}`); + + if (!this.viewportData || + !this.viewportData.length) { + return; + } + + // Clone the array for later + this.previousViewportData = this.viewportData.slice(0); + + const singleViewportData = $.extend({}, this.viewportData[viewportIndex]); + singleViewportData.rows = 1; + singleViewportData.columns = 1; + singleViewportData.viewportIndex = 0; + + const data = { + viewportData: [singleViewportData], + rows: 1, + columns: 1 + }; + + const layoutTemplate = Template['gridLayout']; + + $(this.parentNode).html(''); + Blaze.renderWithData(layoutTemplate, data, this.parentNode); + + this.isZoomed = true; + this.zoomedViewportIndex = viewportIndex; + this.viewportData = data.viewportData; + + this.updateSession(); + } + + resetPreviousLayout() { + OHIF.log.info('LayoutManager resetPreviousLayout'); + + if (!this.isZoomed) { + return; + } + + this.previousViewportData[this.zoomedViewportIndex] = $.extend({}, this.viewportData[0]); + this.previousViewportData[this.zoomedViewportIndex].viewportIndex = this.zoomedViewportIndex; + this.viewportData = this.previousViewportData; + this.updateViewports(); + } + + toggleEnlargement(viewportIndex) { + OHIF.log.info(`LayoutManager toggleEnlargement: ${viewportIndex}`); + + if (this.isZoomed) { + this.resetPreviousLayout(); + } + else { + // Don't enlarge the viewport if we only have one Viewport + // to begin with + if (this.getNumberOfViewports() > 1) { + this.enlargeViewport(viewportIndex); + } + } + } + + // Return the display sets map sequence of display sets and viewports + getDisplaySetSequenceMap() { + OHIF.log.info('LayoutManager getDisplaySetSequenceMap'); + + // Get the viewport data list + const viewportDataList = this.viewportData; + + // Create a map to control the display set sequence + const sequenceMap = new Map(); + + // Iterate over each viewport and register its details on the sequence map + viewportDataList.forEach((viewportData, viewportIndex) => { + // Get the current study + const currentStudy = _.findWhere(this.studies, { + studyInstanceUid: viewportData.studyInstanceUid + }) || this.studies[0]; + + // Get the display sets + const displaySets = currentStudy.series; + + // Get the current display set + const displaySet = _.findWhere(displaySets, { + displaySetInstanceUid: viewportData.displaySetInstanceUid + }); + + // Get the current instance index (using 9999 to sort greater than -1) + let displaySetIndex = _.indexOf(displaySets, displaySet); + displaySetIndex = displaySetIndex < 0 ? 9999 : displaySetIndex; + + // Try to get a map entry for current study or create it if not present + let studyViewports = sequenceMap.get(currentStudy); + if (!studyViewports) { + studyViewports = []; + sequenceMap.set(currentStudy, studyViewports); + } + + // Register the viewport index and the display set index on the map + studyViewports.push({ + viewportIndex, + displaySetIndex + }); + }); + + // Return the generated sequence map + return sequenceMap; + } + + // Check if all the display sets and viewports are sequenced + isDisplaySetsSequenced(definedSequenceMap) { + OHIF.log.info('LayoutManager isDisplaySetsSequenced'); + + let isSequenced = true; + + // Get the studies and display sets sequence map + const sequenceMap = definedSequenceMap || this.getDisplaySetSequenceMap(); + + sequenceMap.forEach((studyViewports, study) => { + let lastDisplaySetIndex = null; + let lastViewportIndex = null; + studyViewports.forEach(({ viewportIndex, displaySetIndex }, index) => { + // Check if the sequence is wrong + if ( + displaySetIndex !== 9999 && + lastViewportIndex !== null && + lastDisplaySetIndex !== null && + displaySetIndex !== null && + (viewportIndex - 1 !== lastViewportIndex || + displaySetIndex - 1 !== lastDisplaySetIndex) + ) { + // Set the sequenced flag as false; + isSequenced = false; + } + + // Update the last viewport index + lastViewportIndex = viewportIndex; + + // Update the last display set index + lastDisplaySetIndex = displaySetIndex; + }); + }); + + return isSequenced; + } + + // Check if is possible to move display sets on a specific direction + canMoveDisplaySets(isNext) { + OHIF.log.info('LayoutManager canMoveDisplaySets'); + + // Get the setting that defines if the display set navigation is multiple + const isMultiple = OHIF.uiSettings.displaySetNavigationMultipleViewports; + + // Get the setting that allow display set navigation looping over series + const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries; + + // Get the studies and display sets sequence map + const sequenceMap = this.getDisplaySetSequenceMap(); + + // Check if the display sets are sequenced + const isSequenced = this.isDisplaySetsSequenced(sequenceMap); + + // Get Active Viewport Index if isMultiple is false + const activeViewportIndex = !isMultiple ? Session.get('activeViewport') : null; + + // Check if is next and looping is blocked + if (isNext && !allowLooping) { + // Check if the end was reached + let endReached = true; + + sequenceMap.forEach((studyViewports, study) => { + // Get active viewport index if isMultiple is false ortherwise get last + const studyViewport = studyViewports[activeViewportIndex !== null ? activeViewportIndex : studyViewports.length - 1]; + if (!studyViewport) { + return; + } + + const viewportIndex = studyViewport.displaySetIndex; + const layoutViewports = studyViewports.length; + const amount = study.displaySets.length; + const move = !isMultiple ? 1 : ((amount % layoutViewports) || layoutViewports); + const lastStepIndex = amount - move; + + // 9999 for index means empty viewport, see getDisplaySetSequenceMap function + if (viewportIndex !== 9999 && viewportIndex !== lastStepIndex) { + endReached = false; + } + }); + + // Return false if end is not reached yet + if ((!isMultiple || isSequenced) && endReached) { + return false; + } + } + + // Check if is previous and looping is blocked + if (!isNext && !allowLooping) { + // Check if the begin was reached + let beginReached = true; + + if(activeViewportIndex >= 0) { + sequenceMap.forEach((studyViewports, study) => { + // Get active viewport index if isMultiple is false ortherwise get first + const studyViewport = studyViewports[activeViewportIndex !== null ? activeViewportIndex : 0]; + if (!studyViewport) { + return; + } + + const viewportIndex = studyViewport.displaySetIndex; + const layoutViewports = studyViewports.length; + + // 9999 for index means empty viewport, see getDisplaySetSequenceMap function + if (viewportIndex !== 9999 && viewportIndex - layoutViewports !== -layoutViewports) { + beginReached = false; + } + }); + } + + // Return false if begin is not reached yet + if ((!isMultiple || isSequenced) && beginReached) { + return false; + } + } + + return true; + } + + // Move display sets forward or backward in the given viewport index + moveSingleViewportDisplaySets(viewportIndex, isNext) { + OHIF.log.info(`LayoutManager moveSingleViewportDisplaySets: ${viewportIndex}`); + + // Get the setting that allow display set navigation looping over series + const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries; + + // Get the selected viewport data + const viewportData = this.viewportData[viewportIndex]; + + // Get the current study + const currentStudy = _.findWhere(this.studies, { + studyInstanceUid: viewportData.studyInstanceUid + }) || this.studies[0]; + + // Get the display sets + const displaySets = currentStudy.displaySets; + + // Get the current display set + const currentDisplaySet = _.findWhere(displaySets, { + displaySetInstanceUid: viewportData.displaySetInstanceUid + }); + + // Get the new index and ensure that it will exists in display sets + let newIndex = _.indexOf(displaySets, currentDisplaySet); + if (isNext) { + newIndex++; + if (newIndex >= displaySets.length) { + // Stop here if looping is not allowed + if (!allowLooping) { + return; + } + + newIndex = 0; + } + } else { + newIndex--; + if (newIndex < 0) { + // Stop here if looping is not allowed + if (!allowLooping) { + return; + } + + newIndex = displaySets.length - 1; + } + } + + // Get the display set data for the new index + const newDisplaySetData = displaySets[newIndex]; + + // Rerender the viewport using the new display set data + this.rerenderViewportWithNewDisplaySet(viewportIndex, newDisplaySetData); + } + + // Move multiple display sets forward or backward in all viewports + moveMultipleViewportDisplaySets(isNext) { + OHIF.log.info('LayoutManager moveMultipleViewportDisplaySets'); + + // Get the setting that allow display set navigation looping over series + const allowLooping = OHIF.uiSettings.displaySetNavigationLoopOverSeries; + + // Create a map to control the display set sequence + const sequenceMap = new this.getDisplaySetSequenceMap(); + + // Check if the display sets are sequenced + const isSequenced = this.isDisplaySetsSequenced(sequenceMap); + + const displaySetsToRender = []; + + // Iterate over the studies map and move its display sets + sequenceMap.forEach((studyViewports, study) => { + // Sort the viewports on the study by the display set index + studyViewports.sort((a, b) => a.displaySetIndex > b.displaySetIndex); + + // Get the study display sets + const displaySets = study.displaySets; + + // Calculate the base index + const firstIndex = studyViewports[0].displaySetIndex; + const steps = studyViewports.length; + const rest = firstIndex % steps; + let baseIndex = rest ? firstIndex - rest : firstIndex; + const direction = isNext ? 1 : -1; + baseIndex += steps * direction; + + const amount = displaySets.length; + + // Check if the indexes are sequenced or will overflow the array bounds + if (baseIndex >= amount) { + const move = (amount % steps) || steps; + const lastStepIndex = amount - move; + if (firstIndex + steps !== lastStepIndex + steps) { + // Reset the index if the display sets are sequenced but shifted + baseIndex = lastStepIndex; + } else if (!allowLooping) { + // Stop here if looping is not allowed + return; + } else { + // Start over the series if looping is allowed + baseIndex = 0; + } + } else if (baseIndex < 0) { + if (firstIndex > 0) { + // Reset the index if the display sets are sequenced but shifted + baseIndex = 0; + } else if (!allowLooping) { + // Stop here if looping is not allowed + return; + } else { + // Go to the series' end if looping is allowed + baseIndex = (amount - 1) - ((amount - 1) % steps); + } + } else if (!isSequenced) { + // Reset the sequence if indexes are not sequenced + baseIndex = 0; + } + + // Iterate over the current study viewports + studyViewports.forEach(({ viewportIndex }, index) => { + // Get the new displaySet index to be rendered in viewport + const newIndex = baseIndex + index; + + // Get the display set data for the new index + const displaySetData = displaySets[newIndex] || {}; + + // Add the current display set that on the render list + displaySetsToRender.push(displaySetData); + }); + }); + + // Sort the display sets + const sortingFunction = OHIF.utils.sortBy({ + name: 'studyInstanceUid' + }, { + name: 'instanceNumber' + }, { + name: 'seriesNumber' + }); + displaySetsToRender.sort((a, b) => sortingFunction(a, b)); + + // Iterate over each display set data and render on its respective viewport + displaySetsToRender.forEach((data, index) => { + this.rerenderViewportWithNewDisplaySet(index, data); + }); + } + + // Move display sets forward or backward + moveDisplaySets(isNext) { + OHIF.log.info('LayoutManager moveDisplaySets'); + + //Check if navigation is on a single or multiple viewports + if (OHIF.uiSettings.displaySetNavigationMultipleViewports) { + // Move display sets on multiple viewports + this.moveMultipleViewportDisplaySets(isNext); + } else { + // Get the selected viewport index + const viewportIndex = Session.get('activeViewport'); + + // Move display sets on a single viewport + this.moveSingleViewportDisplaySets(viewportIndex, isNext); + } + } + + isStudyLoadedIntoViewport(studyInstanceUid, viewportIndex) { + return (this.viewportData.find(item => item.studyInstanceUid === studyInstanceUid && item.viewportIndex === viewportIndex) !== void 0); + } + + isMultipleLayout() { + return this.layoutProps.row !== 1 && this.layoutProps.columns !== 1; + } + +}; diff --git a/Packages/ohif-viewerbase/client/lib/classes/OHIFError.js b/Packages/ohif-viewerbase/client/lib/classes/OHIFError.js new file mode 100644 index 000000000..1e3f4ed43 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/OHIFError.js @@ -0,0 +1,14 @@ +// @TODO: improve this object +/** + * Objects to be used to throw errors, specially + * in Trackers functions (afterFlush, Flush). + */ +export class OHIFError extends Error { + + constructor(message) { + super(); + this.message = message; + this.stack = (new Error()).stack; + this.name = this.constructor.name; + } +} \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/classes/ResizeViewportManager.js b/Packages/ohif-viewerbase/client/lib/classes/ResizeViewportManager.js new file mode 100644 index 000000000..e703fa6a7 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/ResizeViewportManager.js @@ -0,0 +1,153 @@ +import { Session } from 'meteor/session'; +import { $ } from 'meteor/jquery'; +import { OHIF } from 'meteor/ohif:core'; +import { getInstanceClassDefaultViewport } from '../instanceClassSpecificViewport'; + +// Manage resizing viewports triggered by window resize +export class ResizeViewportManager { + constructor() { + OHIF.log.info('ResizeViewportManager'); + } + + // Reposition Study Series Quick Switch based whether side bars are opened or not + repositionStudySeriesQuickSwitch() { + OHIF.log.info('ResizeViewportManager repositionStudySeriesQuickSwitch'); + + const activeTab = Session.get('activeContentId'); + if(activeTab === 'viewerTab') { + const nViewports = OHIF.viewerbase.layoutManager.viewportData.length; + + if(nViewports && nViewports > 1) { + const viewer = $('#viewer'); + const leftSidebar = viewer.find('.sidebar-left.sidebar-open'); + const rightSidebar = viewer.find('.sidebar-right.sidebar-open'); + + const leftQuickSwitch = $('.quickSwitchWrapper.left'); + const rightQuickSwitch = $('.quickSwitchWrapper.right'); + + const hasLeftSidebar = leftSidebar.length > 0; + const hasRightSidebar = rightSidebar.length > 0; + + rightQuickSwitch.removeClass('left-sidebar-only'); + leftQuickSwitch.removeClass('right-sidebar-only'); + + let leftOffset = 0; + + if(hasLeftSidebar) { + leftOffset = ( leftSidebar.width()/$(window).width() ) * 100; + + if(!hasRightSidebar) { + rightQuickSwitch.addClass('left-sidebar-only'); + } + } + + if(hasRightSidebar && !hasLeftSidebar) { + leftQuickSwitch.addClass('right-sidebar-only'); + } + + const leftPosition = ( ($('#imageViewerViewports').width() / nViewports) / $(window).width() ) * 100 + leftOffset; + const rightPosition = 100 - leftPosition; + + leftQuickSwitch.css('right', rightPosition + '%'); + rightQuickSwitch.css('left', leftPosition + '%'); + } + + } + } + + // Relocate dialogs positions + relocateDialogs(){ + OHIF.log.info('ResizeViewportManager relocateDialogs'); + + const bottomRightDialogs = $('#cineDialog, #annotationDialog, #textMarkerOptionsDialog'); + bottomRightDialogs.css({ + top: '', // This removes the CSS property completely + left: '', + bottom: 0, + right: 0 + }); + + const centerDialogs = $('.draggableDialog').not(bottomRightDialogs); + + centerDialogs.css({ + top: 0, + left:0, + bottom: 0, + right: 0 + }); + } + + // Resize viewport scrollbars + resizeScrollbars(element) { + OHIF.log.info('ResizeViewportManager resizeScrollbars'); + + const currentOverlay = $(element).siblings('.imageViewerViewportOverlay'); + const imageControls = currentOverlay.find('.imageControls'); + currentOverlay.find('.imageControls').height($(element).height()); + + // Set it's width to its parent's height + // (because webkit is stupid and can't style vertical sliders) + const scrollbar = currentOverlay.find('#scrollbar'); + scrollbar.height(scrollbar.parent().height() - 20); + + const currentImageSlider = currentOverlay.find('#imageSlider'); + const overlayHeight = currentImageSlider.parent().height(); + const browserInfo = cornerstoneTools.getBrowserInfo(); + + if (browserInfo.indexOf('IE') > -1) { + currentImageSlider.height(overlayHeight); + } else { + currentImageSlider.width(overlayHeight); + } + } + + // Resize a single viewport element + resizeViewportElement(element) { + let enabledElement; + try { + enabledElement = cornerstone.getEnabledElement(element); + } catch(error) { + return; + } + + cornerstone.resize(element, true); + + if (enabledElement.fitToWindow === false) { + const imageId = enabledElement.image.imageId; + const instance = cornerstoneTools.metaData.get('instance', imageId); + const instanceClassViewport = getInstanceClassDefaultViewport(instance, enabledElement, imageId); + cornerstone.setViewport(element, instanceClassViewport); + } + } + + // Resize each viewport element + resizeViewportElements() { + this.relocateDialogs(); + + const viewportResizeTimer = setTimeout(() => { + this.repositionStudySeriesQuickSwitch(); + + const elements = $('.imageViewerViewport').not('.empty'); + elements.each((index, element) => { + this.resizeViewportElement(element); + this.resizeScrollbars(element); + }); + }, 1); + } + + // Function to override resizeViewportElements function + setResizeViewportElement(resizeViewportElements) { + this.resizeViewportElements = resizeViewportElements; + } + + // Avoid doing DOM manipulation during the resize handler + // because it is fired very often. + // Resizing is therefore performed 100 ms after the resize event stops. + handleResize() { + clearTimeout(this.resizeTimer); + this.resizeTimer = setTimeout(() => { + OHIF.log.info('ResizeViewportManager resizeViewportElements'); + this.resizeViewportElements(); + }, 100); + } +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/TypeSafeCollection.js b/Packages/ohif-viewerbase/client/lib/classes/TypeSafeCollection.js new file mode 100644 index 000000000..51501e9b2 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/TypeSafeCollection.js @@ -0,0 +1,330 @@ +import { Random } from 'meteor/random'; +import { ReactiveVar } from 'meteor/reactive-var'; + +const FUNCTION = 'function'; +const OBJECT = 'object'; +const STRING = 'string'; +const PROPERTY_SEPARATOR = '.'; +const ORDER_ASC = 'asc'; +const ORDER_DESC = 'desc'; +const MIN_COUNT = 0x00000000; +const MAX_COUNT = 0x7FFFFFFF; + +export class TypeSafeCollection { + + constructor() { + this._operationCount = new ReactiveVar(MIN_COUNT); + this._elementList = []; + } + + /** + * Private Methods + */ + + _invalidate() { + let count = this._operationCount.get(); + this._operationCount.set(count < MAX_COUNT ? count + 1 : MIN_COUNT); + } + + _elements(silent) { + (silent === true || this._operationCount.get()); + return this._elementList; + } + + _elementWithPayload(payload, silent) { + return this._elements(silent).find(item => item.payload === payload); + } + + _elementWithId(id, silent) { + return this._elements(silent).find(item => item.id === id); + } + + /** + * Static Methods + */ + + /** + * Retrieve an object's property value by name + * @param {Object} object The object we want read the property from... + * @param {String} propertyName The property to be read (e.g., 'address.street.name' or 'address.street.number' + * to read object.address.street.name or object.address.street.number); + * @returns {Any} Returns whatever the property holds or undefined if the property cannot be read or reached. + */ + static getPropertyValue(object, propertyName) { + let propertyValue; + if (typeof object === OBJECT && object !== null && typeof propertyName === STRING) { + let fragments = propertyName.split(PROPERTY_SEPARATOR), + fragmentCount = fragments.length; + if (fragmentCount > 0) { + let firstFragment = fragments[0], + remainingFragments = fragmentCount > 1 ? fragments.slice(1).join(PROPERTY_SEPARATOR) : null; + propertyValue = object[firstFragment]; + if (remainingFragments !== null) { + propertyValue = TypeSafeCollection.getPropertyValue(propertyValue, remainingFragments); + } + } + } + return propertyValue; + } + + /** + * Checks if a sorting specifier is valid. + * A valid sorting specifier consists of an array of arrays being each subarray a pair + * in the format ["property name", "sorting order"]. + * The following exemple can be used to sort studies by "date"" and use "time" to break ties in descending order. + * [ [ 'study.date', 'desc' ], [ 'study.time', 'desc' ] ] + * @param {Array} specifiers The sorting specifier to be tested. + * @returns {boolean} Returns true if the specifiers are valid, false otherwise. + */ + static isValidSortingSpecifier(specifiers) { + let result = true; + if (specifiers instanceof Array && specifiers.length > 0) { + for (let i = specifiers.length - 1; i >= 0; i--) { + const item = specifiers[i]; + if (item instanceof Array) { + const property = item[0]; + const order = item[1]; + if (typeof property === STRING && (order === ORDER_ASC || order === ORDER_DESC)) { + continue; + } + } + result = false; + break; + } + } + return result; + } + + /** + * Sorts an array based on sorting specifier options. + * @param {Array} list The that needs to be sorted. + * @param {Array} specifiers An array of specifiers. Please read isValidSortingSpecifier method definition for further details. + * @returns {void} No value is returned. The array is sorted in place. + */ + static sortListBy(list, specifiers) { + const thisClass = TypeSafeCollection; + if (list instanceof Array && thisClass.isValidSortingSpecifier(specifiers)) { + const getPropertyValue = thisClass.getPropertyValue; + const specifierCount = specifiers.length; + list.sort((a, b) => { + let index = 0; + while (index < specifierCount) { + const specifier = specifiers[index]; + const property = specifier[0]; + const order = specifier[1] === ORDER_DESC ? -1 : 1; + const aValue = getPropertyValue(a, property); + const bValue = getPropertyValue(b, property); + // @TODO: should we check for the types being compared, like: + // ~~ if (typeof aValue !== typeof bValue) continue; + // Not sure because dates, for example, can be correctly compared to numbers... + if (aValue < bValue) { + return order * -1; + } + if (aValue > bValue) { + return order * 1; + } + if (++index >= specifierCount) { + return 0; + } + } + }); + } else { + throw new Error('TypeSafeCollection::sortListBy Invalid Arguments'); + } + } + + /** + * Public Methods + */ + + updateById(id, payload) { + let result = false, + found = this._elementWithPayload(payload, true); + if (found) { + // nothing to do since the element is already in the collection... + if (found.id === id) { + // set result to true since the ids match... + result = true; + this._invalidate(); + } + } else { + found = this._elementWithId(id, true); + if (found) { + found.payload = payload; + result = true; + this._invalidate(); + } + } + return result; + } + + update(payload) { + let result = false, + found = this._elementWithPayload(payload, true); + if (found) { + // nothing to do since the element is already in the collection... + result = true; + this._invalidate(); + } + return result; + } + + insert(payload) { + let id = null, + found = this._elementWithPayload(payload, true); + if (!found) { + id = Random.id(); + this._elements(true).push({ id, payload }); + this._invalidate(); + } + return id; + } + + removeAll() { + let all = this._elements(true), + length = all.length; + for (let i = length - 1; i >= 0; i--) { + let item = all[i]; + delete item.id; + delete item.payload; + all[i] = null; + } + all.splice(0, length); + this._invalidate(); + } + + remove(propertyMap) { + let found = this.findAllBy(propertyMap), + foundCount = found.length, + removed = []; + if (foundCount > 0) { + const all = this._elements(true); + for (let i = foundCount - 1; i >= 0; i--) { + let item = found[i]; + all.splice(item[2], 1); + removed.push(item[0]); + } + this._invalidate(); + } + return removed; + } + + getElementId(payload) { + let found = this._elementWithPayload(payload); + return found && found.id; + } + + findById(id) { + let found = this._elementWithId(id); + return found && found.payload; + } + + indexOfElement(payload) { + return this._elements().indexOf(this._elementWithPayload(payload, true)); + } + + indexOfId(id) { + return this._elements().indexOf(this._elementWithId(id, true)); + } + + getElementByIndex(index) { + let found = ((this._elements())[index >= 0 ? index : -1]); + return found && found.payload; + } + + // the reactive var will not be notified if no valid callback is supplied... + find(callback) { + let found; + if (typeof callback === FUNCTION) { + found = this._elements().find((item, index) => { + return callback.call(this, item.payload, index); + }); + } + return found && found.payload; + } + + // the reactive var will not be notified if no valid property map is supplied... + findBy(propertyMap) { + let found; + if (typeof propertyMap === OBJECT && propertyMap !== null) { + const hasOwn = Object.prototype.hasOwnProperty; + found = this._elements().find(item => { + const payload = item.payload; + for (let propertyName in propertyMap) { + if (hasOwn.call(propertyMap, propertyName)) { + if (propertyMap[propertyName] !== TypeSafeCollection.getPropertyValue(payload, propertyName)) { + return false; + } + } + } + return true; + }); + } + return found && found.payload; + } + + /** + * Find all elements that match a specified property map. + * Attention!!! The reactive source will not be notified if no valid property map is supplied... + * @param {Object} propertyMap A property map that will be macthed against all collection elements. + * @param {Object} options A set of options. Currently only "options.sort" option is supported. + * @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied + * but it's not valid, an exception will be thrown. + * @returns {Array} An array with all elements stored in the collection. + */ + findAllBy(propertyMap, options) { + let found = []; + if (typeof propertyMap === OBJECT && propertyMap !== null) { + const hasOwn = Object.prototype.hasOwnProperty; + this._elements().forEach((item, index) => { + const payload = item.payload; + for (let propertyName in propertyMap) { + if (hasOwn.call(propertyMap, propertyName)) { + if (propertyMap[propertyName] !== TypeSafeCollection.getPropertyValue(payload, propertyName)) { + return; // skip this element since it does not match the criteria... + } + } + } + // Match! Add it to the found list... + found.push([ payload, item.id, index ]); + }); + } + if (options instanceof Object && 'sort' in options) { + TypeSafeCollection.sortListBy(found, options.sort); + } + return found; + } + + // the reactive var will not be notified if no valid callback is supplied... + forEach(callback) { + if (typeof callback === FUNCTION) { + this._elements().forEach((item, index) => { + callback.call(this, item.payload, item.id, index); + }); + } + } + + /** + * Count the number of elements currently in the collection. + * @returns {number} The current number of elements in the collection. + */ + count() { + return this._elements().length; + } + + /** + * Returns a list with all elements of the collection optionally sorted by a sorting specifier criteria. + * @param {Object} options A set of options. Currently only "options.sort" option is supported. + * @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied + * but it's not valid, an exception will be thrown. + * @returns {Array} An array with all elements stored in the collection. + */ + all(options) { + let list = this._elements().map(item => item.payload); + if (options instanceof Object && 'sort' in options) { + TypeSafeCollection.sortListBy(list, options.sort); + } + return list; + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/metadata/InstanceMetadata.js b/Packages/ohif-viewerbase/client/lib/classes/metadata/InstanceMetadata.js new file mode 100644 index 000000000..37c259bfc --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/metadata/InstanceMetadata.js @@ -0,0 +1,173 @@ +import { Metadata } from './Metadata'; + +const UNDEFINED = 'undefined'; +const NUMBER = 'number'; +const STRING = 'string'; +const REGEX_TAG = /^x[0-9a-f]{8}$/; + +export class InstanceMetadata extends Metadata { + + constructor(data) { + super(data); + this._sopInstanceUID = null; + } + + /** + * Returns the SOPInstanceUID of the current instance. + */ + getSOPInstanceUID() { + return this._sopInstanceUID; + } + + // @TODO: Improve this... (E.g.: blob data) + getStringValue(tagOrProperty, index, defaultValue) { + let value = this.getRawValue(tagOrProperty, defaultValue); + + if (typeof value !== STRING && typeof value !== UNDEFINED) { + value = value.toString(); + } + + return InstanceMetadata.getIndexedValue(value, index, defaultValue); + } + + // @TODO: Improve this... (E.g.: blob data) + getFloatValue(tagOrProperty, index, defaultValue) { + let value = this.getRawValue(tagOrProperty, defaultValue); + value = InstanceMetadata.getIndexedValue(value, index, defaultValue); + + if(value instanceof Array) { + value.forEach( (val, idx) => { + value[idx] = parseFloat(val); + }); + + return value; + } + + return typeof value === STRING ? parseFloat(value) : value; + } + + // @TODO: Improve this... (E.g.: blob data) + getIntValue(tagOrProperty, index, defaultValue) { + let value = this.getRawValue(tagOrProperty, defaultValue); + value = InstanceMetadata.getIndexedValue(value, index, defaultValue); + + if(value instanceof Array) { + value.forEach( (val, idx) => { + value[idx] = parseFloat(val); + }); + + return value; + } + + return typeof value === STRING ? parseInt(value) : value; + } + + /** + * + */ + getRawValue(tagOrProperty, defaultValue) { + /** + * Please override this method on a specialized class. + */ + } + + /** + * Compares the current instance with another one. + * @param {InstanceMetadata} instance An instance of the InstanceMetadata class. + * @returns {boolean} Returns true if both instances refer to the same instance. + */ + equals(instance) { + const self = this; + return ( + instance === self || + ( + instance instanceof InstanceMetadata && + instance.getSOPInstanceUID() === self.getSOPInstanceUID() + ) + ); + } + + /** + * Check if the tagOrProperty exists + * @param {String} tagOrProperty tag or property be checked + * @return {Boolean} True if the tag or property exists or false if doesn't + */ + tagExists(tagOrProperty) { + /** + * Please override this method + */ + } + + /** + * Returns a URI for the specified resource related to this instance (if an instance). + * @param {string} resource The name of the resource. This name cannot have any leading or + * trailing slashes + * @param {string|number} [optParameter] An optional parameter. This cannot have any leading or + * trailing slashes + * @returns {string|undefined} The constructed URI for the specified resource related to this + * instance or undefined if this sequence is a sub-sequence (i.e.: it has no related resources of + * its own). + */ + getResourceUri(resource, optParameter) { + /** + * Please override this method + */ + } + + /** + * Static Methods + */ + + static getTagInfo(tagOrProperty) { + let tagName = null, + propertyName = null; + + if (typeof tagOrProperty === NUMBER) { + // if it's a number, build an hexadecimal representation... + tagName = 'x' + ('00000000' + tagOrProperty.toString(16)).substr(-8); + } else if (typeof tagOrProperty === STRING && REGEX_TAG.test(tagOrProperty)) { + tagName = tagOrProperty; + } else { + // use it as a property name otherwise... + propertyName = tagOrProperty; + } + + // @TODO: map tagName to propertyName and vice versa + + return { tagName, propertyName }; + } + + /** + * Get an value based that can be index based. This function is called by all getters. See above functions. + * - If value is a String and has indexes: + * - If undefined index: returns an array of the split values. + * - If defined index: + * - If invalid: returns defaultValue + * - If valid: returns the indexed value + * - If value is not a String, returns default value. + */ + static getIndexedValue(value, index, defaultValue) { + let result = defaultValue; + + if (typeof value === STRING) { + const hasIndexValues = value.indexOf('\\') !== -1; + + result = value; + + if(hasIndexValues) { + const splitValues = value.split('\\'); + if (Metadata.isValidIndex(index)) { + const indexedValue = splitValues[index]; + + result = typeof indexedValue !== STRING ? defaultValue : indexedValue; + } + else { + result = splitValues; + } + } + } + + return result; + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/metadata/Metadata.js b/Packages/ohif-viewerbase/client/lib/classes/metadata/Metadata.js new file mode 100644 index 000000000..1070f8710 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/metadata/Metadata.js @@ -0,0 +1,40 @@ + +/** + * Constants + */ + +const STRING = 'string'; +const NUMBER = 'number'; +const FUNCTION = 'function'; + +export class Metadata { + + /** + * Constructor and Instance Methods + */ + + constructor(data) { + this._data = data; + } + + getData() { + return this._data; + } + + /** + * Static Methods + */ + + static isValidUID(uid) { + return typeof uid === STRING && uid.length > 0; + } + + static isValidIndex(index) { + return typeof index === NUMBER && index >= 0 && (index | 0) === index; + } + + static isValidCallback(callback) { + return typeof callback === FUNCTION; + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/metadata/README.md b/Packages/ohif-viewerbase/client/lib/classes/metadata/README.md new file mode 100644 index 000000000..af0451a8c --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/metadata/README.md @@ -0,0 +1,128 @@ +# Study Metadata Module + +This module defines the API/Data-Model by which OHIF Viewerbase package and possibly distinct viewer +implementations can access studies metadata. This module does not attempt to define any means of +*loading* study metadata from any data end-point but only how the data that has been previously +loaded into the application context will be accessed by any of the routines or algorithm implementations +that need the data. + +## Intro + +For various reasons like sorting, grouping or simply rendering study information, OHIF Viewerbase package +and applications depending on it usualy have the need to access study metadata. Before the current +initiative there was no uniform way of achieving that since each implementation provides study metadata +on its own specific ways. The application and the package itself needed to have a deep knowledge of the +data structures provided by the data endpoint to perform any of the operations mentioned above, meaning +that any data access code needed to be adapted or rewritten. + +The intent of the current module is to provide a fairly consistent and flexible API/Data-Model by which +OHIF Viewerbase package (and different viewer implementations that depend on it) can manipulate DICOM matadata +retrieved from distinct data end points (e.g., a proprietary back end servers) in uniform ways with minor +to no modifications needed. + +## Implementation + +The current API implementation defines three classes of objects: `StudyMetadata`, `SeriesMetadata` +and `InstanceMetadata`. Inside OHIF Viewerbase package, every access to Study, Series or SOP Instance +metadata is achieved by the interface exposed by these three classes. By inheriting from them and +overriding or extending their methods, different applications with different data models can adapt +even the most peculiar data structures to the uniform interface defined by those classes. Together +these classes define a flexible and extensible data manipulation layer leaving routines and +algorithms that depend on that data untouched. + +## Design Decisions & "*Protected*" Members + +In order to provide for good programming practices, attributes and methods meant to be used exclusevily by +the classes themselves (for internal purposes only) were written with an initial '_' character, being thus treated +as "*protected*" members. The idea behind this practice was never to hide them from the programmers +(what makes debugging tasks painful) but only advise for something that's not part of the official public API +and thus should not be relied on. Usage of "protected" members makes the code less readable and prone to +compatibility issues. + +As an example, the initial implementation of the `StudyMetadata` class defined the attribute `_studyInstanceUID` +and the method `getStudyInstanceUID`. This implies that whenever the *StudyInstanceUID* of a given study needs +to be retrieved the `getStudyInstanceUID` method should be called instead of directly accessing the +attribute `_studyInstanceUID` (which might not even be populated since `getStudyInstanceUID` can be possiblity +overriden by a subclass to satisfy specific implementation needs, leaving the attribute `_studyInstanceUID` unused). + +Ex: + +```javascript +let studyUID = myStudy.getStudyInstanceUID(); // GOOD! :-) +[ ... ] +let otherStudyUID = anotherStudy._studyInstanceUID; // BAD... :-( +``` + +Another important topic is the preference of *methods* over *attributes* on the public API. This design +decision was made to ensure extensibility and flexibility (methods are extensible while standalone +attributes are not, and can be adapted – through overrides, for example – to support even the most +peculiar data models) even though the overhead a few additional function calls may incur. + +## Abstract Classes + +Some classes defined in this module are "*abstract*" classes (even though JavaScript does not *officially* +support such programming facility). They are *abstract* in the sense that a few methods (very important ones, +by the way) were left "*blank*" (unimplemented, or more precisely implemented as empty NOP functions) in +order to be implemented by specialized subclasses. Methods believed to be more generic were implemented in +an attempt to satify most implementation needs but nothing prevents a subclass from overriding them as well +(again, flexibility and extensibility are design goals). Most implemented methods rely on the implementation +of an unimplemented method. For example, the method `getStringValue` from `InstanceMetadata` class, which +has indeed been implemented and is meant to retrieve a metadata value as a string, internally calls the +`getRawValue` method which *was NOT implemented* and is meant to query the internal data structures for the +requested metadata value and return it *as is*. Used in that way, an application would not benefit much +from the already implemented methods. On the other hand, by simply overriding the `getRawValue` method +on a specialized class to deal with the intrinsics of its internal data structures, this very application +would now benefit from all already implemented methods. + +The following code snippet tries to illustrate the idea: + +```javascript + +// -- InstanceMetadata.js + +class InstanceMetadata { + [ ... ] + getRawValue(tagOrProperty, defaultValue) { + // Please implement this method in a specialized subclass... + } + [ ... ] + getStringValue(tagOrProperty, index, defaultValue) { + let rawValue = this.getRawValue(tagOrProperty, ''); + // parse the returned value into a string... + [ ... ] + return stringValue; + } + [ ... ] +} + +// -- MyFancyAppInstanceMetadata.js + +class MyFancyAppInstanceMetadata extends InstanceMetadata { + // Overriding this method will make all methods implemented in the super class + // that rely on it to be immediately available... + getRawValue(tagOrProperty, defaultValue) { + let rawValue; + // retrieve raw value from internal data structures... + [ ... ] + return rawValue; + } +} + +// -- main.js + +[ ... ] +let sopInstaceMetadata = new MyFancyAppInstanceMetadata(myInternalData); +if (sopInstaceMetadata instanceof MyFancyAppInstanceMetadata) { // true + // this code will be executed... +} +if (sopInstaceMetadata instanceof InstanceMetadata) { // also true + // this code will also be executed... +} +// The following will also work since the internal "getRawValue" call inside +// "getStringValue" method will now be satisfied... (thanks to the override) +let patientName = sopInstaceMetadata.getStringValue('PatientName', ''); +[ ... ] + +``` + +_Copyright © 2016 nucleushealth™. All rights reserved_ diff --git a/Packages/ohif-viewerbase/client/lib/classes/metadata/SeriesMetadata.js b/Packages/ohif-viewerbase/client/lib/classes/metadata/SeriesMetadata.js new file mode 100644 index 000000000..774a1e3e2 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/metadata/SeriesMetadata.js @@ -0,0 +1,109 @@ +import { Metadata } from './Metadata'; +import { InstanceMetadata } from './InstanceMetadata'; + +export class SeriesMetadata extends Metadata { + + constructor(data) { + super(data); + this._seriesInstanceUID = null; + this._instances = []; // InstanceMetadata[] + } + + /** + * Returns the SeriesInstanceUID of the current series. + */ + getSeriesInstanceUID() { + return this._seriesInstanceUID; + } + + /** + * Append an instance to the current series. + * @param {InstanceMetadata} instance The instance to be added to the current series. + * @returns {boolean} Returns true on success, false otherwise. + */ + addInstance(instance) { + let result = false; + if (instance instanceof InstanceMetadata && this.getInstanceByUID(instance.getSOPInstanceUID()) === void 0) { + this._instances.push(instance); + result = true; + } + return result; + } + + /** + * Find an instance by index. + * @param {number} index An integer representing a list index. + * @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise. + */ + getInstanceByIndex(index) { + let found; // undefined by default... + if (Metadata.isValidIndex(index)) { + found = this._instances[index]; + } + return found; + } + + /** + * Find an instance by SOPInstanceUID. + * @param {string} uid An UID string. + * @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise. + */ + getInstanceByUID(uid) { + let found; // undefined by default... + if (Metadata.isValidUID(uid)) { + found = this._instances.find(instance => { + return instance.getSOPInstanceUID() === uid; + }); + } + return found; + } + + /** + * Retrieve the number of instances within the current series. + * @returns {number} The number of instances in the current series. + */ + getInstanceCount() { + return this._instances.length; + } + + /** + * Invokes the supplied callback for each instance in the current series passing + * two arguments: instance (an InstanceMetadata instance) and index (the integer + * index of the instance within the current series) + * @param {function} callback The callback function which will be invoked for each instance in the series. + * @returns {undefined} Nothing is returned. + */ + forEachInstance(callback) { + if (Metadata.isValidCallback(callback)) { + this._instances.forEach((instance, index) => { + callback.call(null, instance, index); + }); + } + } + + /** + * Find the index of an instance inside the series. + * @param {InstanceMetadata} instance An instance of the SeriesMetadata class. + * @returns {number} The index of the instance inside the series or -1 if not found. + */ + indexOfInstance(instance) { + return this._instances.indexOf(instance); + } + + /** + * Compares the current series with another one. + * @param {SeriesMetadata} series An instance of the SeriesMetadata class. + * @returns {boolean} Returns true if both instances refer to the same series. + */ + equals(series) { + const self = this; + return ( + series === self || + ( + series instanceof SeriesMetadata && + series.getSeriesInstanceUID() === self.getSeriesInstanceUID() + ) + ); + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classes/metadata/StudyMetadata.js b/Packages/ohif-viewerbase/client/lib/classes/metadata/StudyMetadata.js new file mode 100644 index 000000000..5095b2c1f --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classes/metadata/StudyMetadata.js @@ -0,0 +1,134 @@ +import { Metadata } from './Metadata'; +import { SeriesMetadata } from './SeriesMetadata'; + +export class StudyMetadata extends Metadata { + constructor(data) { + super(data); + this._studyInstanceUID = null; + this._series = []; // SeriesMetadata[] + + } + + /** + * Returns the StudyInstanceUID of the current study. + */ + getStudyInstanceUID() { + return this._studyInstanceUID; + } + + /** + * Append a series to the current study. + * @param {SeriesMetadata} series The series to be added to the current study. + * @returns {boolean} Returns true on success, false otherwise. + */ + addSeries(series) { + let result = false; + if (series instanceof SeriesMetadata && this.getSeriesByUID(series.getSeriesInstanceUID()) === void 0) { + this._series.push(series); + result = true; + } + return result; + } + + /** + * Find a series by index. + * @param {number} index An integer representing a list index. + * @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise. + */ + getSeriesByIndex(index) { + let found; // undefined by default... + if (Metadata.isValidIndex(index)) { + found = this._series[index]; + } + return found; + } + + /** + * Find a series by SeriesInstanceUID. + * @param {string} uid An UID string. + * @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise. + */ + getSeriesByUID(uid) { + let found; // undefined by default... + if (Metadata.isValidUID(uid)) { + found = this._series.find(series => { + return series.getSeriesInstanceUID() === uid; + }); + } + return found; + } + + /** + * Retrieve the number of series within the current study. + * @returns {number} The number of series in the current study. + */ + getSeriesCount() { + return this._series.length; + } + + /** + * Retrieve the number of instances within the current study. + * @returns {number} The number of instances in the current study. + */ + getInstanceCount() { + return this._series.reduce((sum, series) => { + return sum + series.getInstanceCount(); + }, 0); + } + + /** + * Invokes the supplied callback for each series in the current study passing + * two arguments: series (a SeriesMetadata instance) and index (the integer + * index of the series within the current study) + * @param {function} callback The callback function which will be invoked for each series instance. + * @returns {undefined} Nothing is returned. + */ + forEachSeries(callback) { + if (Metadata.isValidCallback(callback)) { + this._series.forEach((series, index) => { + callback.call(null, series, index); + }); + } + } + + /** + * Find the index of a series inside the study. + * @param {SeriesMetadata} series An instance of the SeriesMetadata class. + * @returns {number} The index of the series inside the study or -1 if not found. + */ + indexOfSeries(series) { + return this._series.indexOf(series); + } + + /** + * Compares the current study instance with another one. + * @param {StudyMetadata} study An instance of the StudyMetadata class. + * @returns {boolean} Returns true if both instances refer to the same study. + */ + equals(study) { + const self = this; + return ( + study === self || + ( + study instanceof StudyMetadata && + study.getStudyInstanceUID() === self.getStudyInstanceUID() + ) + ); + } + + /** + * Get first instance of the first series + * @return {InstanceMetadata} InstanceMetadata class object or undefined if it doenst exist + */ + getFirstInstance() { + let firstInstance; + const firstSeries = this.getSeriesByIndex(0); + + if (firstSeries) { + firstInstance = firstSeries.getInstanceByIndex(0); + } + + return firstInstance; + } + +} diff --git a/Packages/ohif-viewerbase/client/lib/classifyImageOrientation.js b/Packages/ohif-viewerbase/client/lib/classifyImageOrientation.js new file mode 100644 index 000000000..877c3fa76 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/classifyImageOrientation.js @@ -0,0 +1,57 @@ +import { OHIF } from 'meteor/ohif:core'; + +/** + * This function returns a string describing an image orientation + * (e.g. axial, sagittal, coronal) + * If no classification is determined, it returns undefined + * + * @param {string} [imageId] + * @returns {string} + */ +export function classifyImageOrientation(imageId) { + // First we check if this imageId has already been classified, so we can return + // the classification from the cache + if (OHIF.viewer.linkSeries.classifiedOrientation.hasOwnProperty(imageId)) { + return OHIF.viewer.linkSeries.classifiedOrientation[imageId]; + } + + // Calculate the image plane normal + const imagePlane = cornerstoneTools.metaData.get('imagePlane', imageId); + if (!imagePlane || !imagePlane.rowCosines || !imagePlane.columnCosines) { + return; + } + const imageNormal = imagePlane.rowCosines.clone().cross(imagePlane.columnCosines); + + // These represent the normal vectors to three standard image planes + const normals = { + axial: new cornerstoneMath.Vector3(0, 0, 1), + coronal: new cornerstoneMath.Vector3(0, 1, 0), + sagittal: new cornerstoneMath.Vector3(1, 0, 0) + }; + + const PI = Math.PI; + // This loop checks the angle between the current image plane normal + // and that of the three standard image planes above. If the two + // normal vectors are within 15 degrees of each other, the current image + // is classified as the relevant standard image plane (e.g. axial). + let classifiedOrientation; + Object.keys(normals).some(orientation => { + let angleInRadians = imageNormal.angleTo(normals[orientation]); + + angleInRadians = Math.abs(angleInRadians); + if (angleInRadians < PI / 12 || angleInRadians === PI) { // Pi / 12 radians = 15 degrees + classifiedOrientation = orientation; + return true; + } + }); + + // If no classification was determined, stop here and dump a warning to the console + if (!classifiedOrientation) { + return; + } + + // Otherwise, update the cache with the classified orientation for this imageId + OHIF.viewer.linkSeries.classifiedOrientation[imageId] = classifiedOrientation; + + return classifiedOrientation; +}; \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/crosshairsSynchronizers.js b/Packages/ohif-viewerbase/client/lib/crosshairsSynchronizers.js new file mode 100644 index 000000000..a02bbb7f6 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/crosshairsSynchronizers.js @@ -0,0 +1,4 @@ + +export const crosshairsSynchronizers = { + synchronizers: {} +}; diff --git a/Packages/ohif-viewerbase/client/lib/dialogUtils.js b/Packages/ohif-viewerbase/client/lib/dialogUtils.js new file mode 100644 index 000000000..d39e0d0b7 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/dialogUtils.js @@ -0,0 +1,72 @@ +import { Blaze } from 'meteor/blaze'; +import { Template } from 'meteor/templating'; +import { $ } from 'meteor/jquery'; + +import { setFocusToActiveViewport } from './setFocusToActiveViewport'; + +let doneCallbackFunction; + +/** + * Removes the backdrop abd closes opened dialog + * and focus to the active viewport. If a done callback is set, + * it's called before + * @param {Boolean} runCallback Indicate if callback function needs to be called. Default: true + */ +const closeHandler = (runCallback = true) => { + // Check if callback function exists + if (runCallback && typeof doneCallbackFunction === 'function') { + doneCallbackFunction(); + } + + // Hide the lesion dialog + $('#confirmDeleteDialog').css('display', 'none'); + + // Remove the backdrop + $('.removableBackdrop').remove(); + + // Remove the callback + doneCallbackFunction = undefined; + + // Restore the focus to the active viewport + setFocusToActiveViewport(); +}; + +/** + * Displays the confirmation dialog template and the removable backdrop element + * + * @param doneCallback A callback + * @param options + */ +const showConfirmDialog = (doneCallback, options) => { + // Show the backdrop + options = options || {}; + Blaze.renderWithData(Template.removableBackdrop, options, document.body); + + let confirmDeleteDialog = $('#confirmDeleteDialog'); + confirmDeleteDialog.remove(); + + const viewer = document.getElementById('viewer'); + Blaze.renderWithData(Template.confirmDeleteDialog, options, viewer); + + // Make sure the context menu is closed when the user clicks away + $('.removableBackdrop').one('mousedown touchstart', () => { + // Close dialog without calling callback + closeHandler(false); + }); + + confirmDeleteDialog = $('#confirmDeleteDialog'); + confirmDeleteDialog.css('display', 'block'); + confirmDeleteDialog.focus(); + + // If callback function is defined, save it for closeHandler + if (doneCallback && typeof doneCallback === 'function') { + doneCallbackFunction = doneCallback; + } +}; + +const dialogUtils = { + showConfirmDialog, + closeHandler +}; + +export { dialogUtils }; \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/getElementIfNotEmpty.js b/Packages/ohif-viewerbase/client/lib/getElementIfNotEmpty.js new file mode 100644 index 000000000..2e4d61162 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/getElementIfNotEmpty.js @@ -0,0 +1,27 @@ +import { $ } from 'meteor/jquery'; + +export function getElementIfNotEmpty(viewportIndex) { + // Meteor template helpers run more often than expected + // They often seem to run just before the whole template is rendered + // This meant that the onRendered event hadn't fired yet, so the + // element wasn't enabled / set empty yet. The check here + // for canvases under the 'enabled' element div is to prevent + // 'undefined' errors from the helper functions + + var imageViewerViewports = $('.imageViewerViewport'), + element = imageViewerViewports.get(viewportIndex), + canvases = imageViewerViewports.eq(viewportIndex).find('canvas'); + + if (!element || $(element).hasClass('empty') || canvases.length === 0) { + return; + } + + // Check to make sure the element is enabled. + try { + var enabledElement = cornerstone.getEnabledElement(element); + } catch(error) { + return; + } + + return element; +} diff --git a/Packages/ohif-viewerbase/client/lib/getFrameOfReferenceUID.js b/Packages/ohif-viewerbase/client/lib/getFrameOfReferenceUID.js new file mode 100644 index 000000000..a0e7bf7f8 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/getFrameOfReferenceUID.js @@ -0,0 +1,30 @@ +/** + * Helper function to quickly obtain the frameOfReferenceUID + * for a given element from the enabled image's metadata. + * + * If no image, imagePlane, or frameOfReferenceUID is available, + * the function will return undefined. + * + * @param element + * @returns {string} + */ +export function getFrameOfReferenceUID(element) { + var enabledElement; + try { + enabledElement = cornerstone.getEnabledElement(element); + } catch(error) { + return; + } + + if (!enabledElement || !enabledElement.image) { + return; + } + + var imageId = enabledElement.image.imageId; + var imagePlane = cornerstoneTools.metaData.get('imagePlane', imageId); + if (!imagePlane || !imagePlane.frameOfReferenceUID) { + return; + } + + return imagePlane.frameOfReferenceUID; +} diff --git a/Packages/ohif-viewerbase/client/lib/getSortedData.js b/Packages/ohif-viewerbase/client/lib/getSortedData.js new file mode 100644 index 000000000..0cca2285f --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/getSortedData.js @@ -0,0 +1,251 @@ +import { ImageSet } from './classes/ImageSet'; + +const sortBySeriesNumberAndAcquisition = (a, b) => { + if (a.seriesNumber && b.seriesNumber) { + if (a.seriesNumber !== b.seriesNumber) { + return a.seriesNumber - b.seriesNumber; + } + } + + if (a.acquisitionDateTime && b.acquisitionDateTime) { + return a.acquisitionDateTime.localeCompare(b.acquisitionDateTime); + } +}; + +const makeImageSet = sopInstances => { + const imageSet = new ImageSet(sopInstances); + const sopInstance = sopInstances[0]; + + // list of attributes to be appended to ImageSet instance... + const attributes = { + seriesInstanceUid: sopInstance.getRawValue('x0020000e'), + seriesNumber: sopInstance.getIntValue('x00200011'), + seriesDescription: sopInstance.getRawValue('x0008103e', ''), + acquisitionDateTime: sopInstance.getRawValue('x00080022', '') + sopInstance.getRawValue('x00080032', ''), + numImageFrames: sopInstances.length, + frameRate: sopInstance.getFloatValue('x00181063', 0, 0), + studyInstanceUid: sopInstance.getRawValue('x0020000d'), + displaySetInstanceUid: imageSet.uid // create an alias for the imageSet UID + }; + + const frameTime = sopInstance.getFloatValue('x00181063', 0, 0); + const recommendedDisplayFrameRate = sopInstance.getFloatValue('x00082144', 0, 0); + if (frameTime) { + // FrameTime is in milliseconds, so we use 1000/frameTime to calculate + // the number of frames per second. + attributes.frameRate = 1000 / frameTime; + attributes.isClip = true; + } else if (recommendedDisplayFrameRate) { + // Recommended Display FrameRate is specified in frames per second as an integer + attributes.frameRate = recommendedDisplayFrameRate; + attributes.isClip = true; + } + + imageSet.setAttributes(attributes); + + // Sort the images in this series + imageSet.sortBy( (a, b) => { + const aInstanceNumber = a.getFloatValue('x00200013'); + const bInstanceNumber = b.getFloatValue('x00200013'); + + if (aInstanceNumber && bInstanceNumber) { + if (aInstanceNumber !== bInstanceNumber) { + return aInstanceNumber - bInstanceNumber; + } + } + + const aAcquisitionNumber = a.getFloatValue('x00200012'); + const bAcquisitionNumber = b.getFloatValue('x00200012'); + + if (aAcquisitionNumber && bAcquisitionNumber) { + if (aAcquisitionNumber !== bAcquisitionNumber) { + return aAcquisitionNumber - bAcquisitionNumber; + } + } + + const aAcquisitionDateTime = a.getRawValue('x00080022', '') + a.getRawValue('x00080032', ''); + const bAcquisitionDateTime = b.getRawValue('x00080022', '') + b.getRawValue('x00080032', ''); + + if (aAcquisitionDateTime && bAcquisitionDateTime) { + return aAcquisitionDateTime.localeCompare(bAcquisitionDateTime); + } + }); + + return imageSet; +}; + +const isMultiFrame = instanceView => { + const numFrames = instanceView.getIntValue('x00280008'); + return (numFrames > 1); +}; + +const isSingleImageModality = modality => { + const singleImageModalities = ['CR', 'MG', 'DX', 'RG', 'PX', 'XA', 'XR'] + return singleImageModalities.indexOf(modality) > -1; +}; + +const seriesSplittingRules = [ +{ + tag: 'x00180010', // ContrastBolusAgent +}, +{ + tag: 'x00180086', // Echo Number +}, + +// --- Some other potential examples of tags to split on --- // +/*{ + tag: 'x00180020', // Scanning Sequence +}, +{ + tag: 'x00180050', // Slice Thickness +},*/ +/*{ + tag: 'x00180081', // Echo Time +}, +{ + tag: 'x00181210', // Convolution Kernel +},*/ +/*{ + tag: 'x00200012', // Acquisition Number +}*/ +]; + +const stackOfInstancesToDisplaySets = (sopInstances, seriesView) => { + // If no instances are provided, return an empty array + if (!sopInstances.length) { + return []; + } + + // Create an empty array to store our display sets in + const displaySets = []; + + // Use Underscore's groupBy function to group our sopInstances + // by a key. In our case, the key is a combined string which represents + // the value of the DICOM tags listed in the seriesSplittingRules array. + // Currently this uses tags for EchoNumber and ContrastBolusAgent. + const groups = _.groupBy(sopInstances, sopInstance => { + // Create an empty string to store our key + let result = ''; + const instanceView = sopInstance.getData(); + + // For each rule, find out if the tag exists + seriesSplittingRules.forEach(rule => { + // Include a delimitation character between each tag value + result += ';' + + // If the tag exists, concatenate its value to our key + if (instanceView.exists(rule.tag)) { + result += instanceView.raw(rule.tag); + } + }); + + // Return the key to be used for grouping. + return result; + }) + + const seriesInstanceCount = seriesView.getInstanceCount(); + + Object.keys(groups).forEach(groupKey => { + // For each group, obtain the instances in the stack + const stackInstances = groups[groupKey]; + + // Create a new display set from the images in this stack + const displaySet = makeImageSet(stackInstances); + displaySet.setAttribute('seriesInstanceCount', seriesInstanceCount); + + displaySets.push(displaySet); + }); + + return displaySets; +}; + +/** + * This function extracts and produces sorted lists of image sets and non-viewable instance + * sets from a studyMetadata {StudyMetadata instance}. + * + * If an optional series number is specified, only the image set with this series number + * will be returned (as the sole element in an array). + * + * The function returns an object with two fields, imageSets and nonViewable, both of which + * are arrays. imageSets is an array of sorted image sets and nonViewable is an array of + * non-viewable sopInstances. + * + * @param studyMetadata + * @param {number} [seriesNumber] One series number. If specified, the function only returns the imageSet with this seriesNumber + * + * @returns {{imageSets: Array, nonViewable: Array}} + */ +getSortedData = function(studyMetadata, seriesNumber) { + let imageSets = []; + let nonViewable = []; + + studyMetadata.forEachSeries((series, seriesNum) => { + const firstInstance = series.getInstanceByIndex(0); + + // If a specific series number is given, skip all series except this one + if (seriesNumber !== undefined && firstInstance.getIntValue('x00200011', '') !== seriesNumber) { + return; + } + + let sopInstances = []; + let imageSet; + + const seriesInstanceCount = series.getInstanceCount(); + + series.forEachInstance(instanceMetadata => { + // Skip non image types (an image must have the 'rows' tag) + const rows = instanceMetadata.tagExists('x00280010'); + + if (!rows) { + nonViewable.push(instanceMetadata); + return; + } + + const modality = firstInstance.getRawValue('x00080060', ''); + const singleImageModality = isSingleImageModality(modality); + + // If we detect a multi-frame instance + if (isMultiFrame(instanceMetadata)) { + // First, sort all sopInstances currently in our stack into display sets using + // our series splitting rules + const displaySets = stackOfInstancesToDisplaySets(sopInstances, series); + imageSets = imageSets.concat(displaySets); + + // Next, make another display set for the multi-frame instance + imageSet = makeImageSet([ instanceMetadata ]); + imageSet.setAttributes({ + seriesInstanceCount: seriesInstanceCount, + numImageFrames: instanceMetadata.getIntValue('x00280008', 0) + }); + + imageSets.push(imageSet); + + // Reset our stack of sopInstances + sopInstances = []; + } else if (singleImageModality) { + imageSet = makeImageSet([ instanceMetadata ]); + imageSet.setAttribute('seriesInstanceCount', seriesInstanceCount); + imageSets.push(imageSet); + } else { + // If no multi-frame instances have been discovered + sopInstances.push(instanceMetadata); + } + + }); + + // Create display sets from any remaining sopInstances in the series + const displaySets = stackOfInstancesToDisplaySets(sopInstances, series); + imageSets = imageSets.concat(displaySets); + }); + + // sort imageSets + imageSets.sort(sortBySeriesNumberAndAcquisition); + nonViewable.sort(sortBySeriesNumberAndAcquisition); + + return { + imageSets, + nonViewable + }; +}; + +export { getSortedData }; \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/getStackDataIfNotEmpty.js b/Packages/ohif-viewerbase/client/lib/getStackDataIfNotEmpty.js new file mode 100644 index 000000000..c5e1c4d81 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/getStackDataIfNotEmpty.js @@ -0,0 +1,22 @@ +import { getElementIfNotEmpty } from './getElementIfNotEmpty.js'; + +export function getStackDataIfNotEmpty(viewportIndex) { + const element = getElementIfNotEmpty(viewportIndex); + if (!element) { + return; + } + + const stackToolData = cornerstoneTools.getToolState(element, 'stack'); + if (!stackToolData || + !stackToolData.data || + !stackToolData.data.length) { + return; + } + + const stack = stackToolData.data[0]; + if (!stack) { + return; + } + + return stack; +} diff --git a/Packages/ohif-viewerbase/client/lib/helpers/index.js b/Packages/ohif-viewerbase/client/lib/helpers/index.js new file mode 100644 index 000000000..867832cc5 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/helpers/index.js @@ -0,0 +1,39 @@ +/** + * Helpers with exposed symbols... + */ + +import { isTouchDevice } from './isTouchDevice'; +import { formatPN } from './formatPN'; +import { formatDA } from './formatDA'; +import { formatTM } from './formatTM'; + +/** + * Helpers with side effects only... + */ + +import './formatJSDate'; +import './jsDateFromNow'; +import './formatNumberPrecision'; +import './inc'; +import './isDisplaySetActive'; +import './getUsername'; +import './capitalizeFirstLetter'; +import './objectToPairs'; +import './objectEach'; +import './ifTypeIs'; +import './prettyPrintStringify'; +import './sorting'; +import './studyThumbnails'; + +/** + * Exposed interface... + */ + +const helpers = { + isTouchDevice, + formatPN, + formatDA, + formatTM +}; + +export { helpers }; diff --git a/Packages/ohif-viewerbase/client/lib/hotkeyUtils.js b/Packages/ohif-viewerbase/client/lib/hotkeyUtils.js new file mode 100644 index 000000000..d94a5aae3 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/hotkeyUtils.js @@ -0,0 +1,375 @@ +import { Meteor } from 'meteor/meteor'; +import { $ } from 'meteor/jquery'; +import { OHIF } from 'meteor/ohif:core'; +import { toolManager } from './toolManager'; +import { setActiveViewport } from './setActiveViewport'; +import { switchToImageRelative } from './switchToImageRelative'; +import { switchToImageByIndex } from './switchToImageByIndex'; +import { viewportUtils } from './viewportUtils'; +import { panelNavigation } from './panelNavigation'; +import { seriesNavigation } from './seriesNavigation'; +import { WLPresets } from './WLPresets'; + +// TODO: add this to namespace definitions +Meteor.startup(function() { + if (!OHIF.viewer) { + OHIF.viewer = {}; + } + + OHIF.viewer.loadIndicatorDelay = 200; + OHIF.viewer.defaultTool = 'wwwc'; + OHIF.viewer.refLinesEnabled = true; + OHIF.viewer.isPlaying = {}; + OHIF.viewer.cine = { + framesPerSecond: 24, + loop: true + }; + + OHIF.viewer.defaultHotkeys = { + defaultTool: 'ESC', + angle: 'A', + stackScroll: 'S', + pan: 'P', + magnify: 'M', + scrollDown: 'DOWN', + scrollUp: 'UP', + nextDisplaySet: 'PAGEDOWN', + previousDisplaySet: 'PAGEUP', + nextPanel: 'RIGHT', + previousPanel: 'LEFT', + invert: 'I', + flipV: 'V', + flipH: 'H', + wwwc: 'W', + zoom: 'Z', + cinePlay: 'SPACE', + rotateR: 'R', + rotateL: 'L', + toggleOverlayTags: 'SHIFT', + WLPresetSoftTissue: ['NUMPAD1', '1'], + WLPresetLung: ['NUMPAD2', '2'], + WLPresetLiver: ['NUMPAD3', '3'], + WLPresetBone: ['NUMPAD4', '4'], + WLPresetBrain: ['NUMPAD5', '5'] + }; + + // For now + OHIF.viewer.hotkeys = OHIF.viewer.defaultHotkeys; + + OHIF.viewer.hotkeyFunctions = { + wwwc() { + toolManager.setActiveTool('wwwc'); + }, + zoom() { + toolManager.setActiveTool('zoom'); + }, + angle() { + toolManager.setActiveTool('angle'); + }, + dragProbe() { + toolManager.setActiveTool('dragProbe'); + }, + ellipticalRoi() { + toolManager.setActiveTool('ellipticalRoi'); + }, + magnify() { + toolManager.setActiveTool('magnify'); + }, + annotate() { + toolManager.setActiveTool('annotate'); + }, + stackScroll() { + toolManager.setActiveTool('stackScroll'); + }, + pan() { + toolManager.setActiveTool('pan'); + }, + length() { + toolManager.setActiveTool('length'); + }, + spine() { + toolManager.setActiveTool('spine'); + }, + wwwcRegion() { + toolManager.setActiveTool('wwwcRegion'); + }, + zoomIn() { + const button = document.getElementById('zoomIn'); + flashButton(button); + viewportUtils.zoomIn(); + }, + zoomOut() { + const button = document.getElementById('zoomOut'); + flashButton(button); + viewportUtils.zoomOut(); + }, + zoomToFit() { + const button = document.getElementById('zoomToFit'); + flashButton(button); + viewportUtils.zoomToFit(); + }, + scrollDown() { + const container = $('.viewportContainer.active'); + const button = container.find('#nextImage').get(0); + + if (!container.find('.imageViewerViewport').hasClass('empty')) { + flashButton(button); + switchToImageRelative(1); + } + }, + scrollFirstImage() { + const container = $('.viewportContainer.active'); + if (!container.find('.imageViewerViewport').hasClass('empty')) { + switchToImageByIndex(0); + } + }, + scrollLastImage() { + const container = $('.viewportContainer.active'); + if (!container.find('.imageViewerViewport').hasClass('empty')) { + switchToImageByIndex(-1); + } + }, + scrollUp() { + const container = $('.viewportContainer.active'); + if (!container.find('.imageViewerViewport').hasClass('empty')) { + const button = container.find('#prevImage').get(0); + flashButton(button); + switchToImageRelative(-1); + } + }, + previousDisplaySet() { + OHIF.viewer.moveDisplaySets(false); + }, + nextDisplaySet() { + OHIF.viewer.moveDisplaySets(true); + }, + nextPanel() { + panelNavigation.loadNextActivePanel(); + }, + previousPanel() { + panelNavigation.loadPreviousActivePanel(); + }, + invert() { + const button = document.getElementById('invert'); + flashButton(button); + viewportUtils.invert(); + }, + flipV() { + const button = document.getElementById('flipV'); + flashButton(button); + viewportUtils.flipV(); + }, + flipH() { + const button = document.getElementById('flipH'); + flashButton(button); + viewportUtils.flipH(); + }, + rotateR() { + const button = document.getElementById('rotateR'); + flashButton(button); + viewportUtils.rotateR(); + }, + rotateL() { + const button = document.getElementById('rotateL'); + flashButton(button); + viewportUtils.rotateL(); + }, + cinePlay() { + viewportUtils.toggleCinePlay(); + }, + defaultTool() { + const tool = toolManager.getDefaultTool(); + toolManager.setActiveTool(tool); + }, + toggleOverlayTags() { + const dicomTags = $('.imageViewerViewportOverlay .dicomTag'); + if (dicomTags.eq(0).css('display') === 'none') { + dicomTags.show(); + } else { + dicomTags.hide(); + } + }, + resetStack() { + const button = document.getElementById('resetStack'); + flashButton(button); + resetStack(); + }, + clearImageAnnotations() { + const button = document.getElementById('clearImageAnnotations'); + flashButton(button); + clearImageAnnotations(); + }, + nextSeries () { + seriesNavigation.loadNextSeries(); + }, + previousSeries () { + seriesNavigation.loadPreviousSeries(); + }, + cineDialog () { + /** + * TODO: This won't work in OHIF's, since this element + * doesn't exist + */ + const button = document.getElementById('cine'); + flashButton(button); + viewportUtils.toggleCineDialog(); + button.classList.toggle('active'); + } + }; + + OHIF.viewer.loadedSeriesData = {}; + OHIF.viewer.linkSeries = { + classifiedOrientation: {} + }; +}); + +// Define a jQuery reverse function +$.fn.reverse = [].reverse; + +/** + * Overrides OHIF's refLinesEnabled + * @param {Boolean} refLinesEnabled True to enable and False to disable + */ +function setOHIFRefLines(refLinesEnabled) { + OHIF.viewer.refLinesEnabled = refLinesEnabled; +} + +/** + * Overrides OHIF's hotkeys + * @param {Object} hotkeys Object with hotkeys mapping + */ +function setOHIFHotkeys(hotkeys) { + OHIF.viewer.hotkeys = hotkeys; +} + +/** + * Global function to merge different hotkeys configurations + * but avoiding conflicts between different keys with same action + * When this occurs, it will delete the action from OHIF's configuration + * So if you want to keep all OHIF's actions, use an unused-ohif-key + * Used for compatibility with others systems only + * + * @param hotkeysActions {object} Object with actions map + * @return {object} + */ +function mergeHotkeys(hotkeysActions) { + // Merge hotkeys, overriding OHIF's settings + let mergedHotkeys = { + ...OHIF.viewer.defaultHotkeys, + ...hotkeysActions + }; + + const defaultHotkeys = OHIF.viewer.defaultHotkeys; + const hotkeysKeys = Object.keys(hotkeysActions); + + // Check for conflicts with same keys but different actions + Object.keys(defaultHotkeys).forEach(ohifAction => { + hotkeysKeys.forEach(definedAction => { + // Different action but same key: + // Remove action from merge if is not in "hotkeysActions" + // If it is, it's already merged so nothing to do + if(ohifAction !== definedAction && hotkeysActions[definedAction] === defaultHotkeys[ohifAction] && !hotkeysActions[ohifAction]) { + delete mergedHotkeys[ohifAction]; + } + }); + }); + + return mergedHotkeys; +} + +/** + * Add an active class to a button for 100ms only + * to give the impressiont the button was pressed. + * This is for tools that don't keep the button "pressed" + * all the time the tool is active. + * + * @param button DOM Element for the button to be "flashed" + */ +function flashButton(button) { + if (!button) { + return; + } + + button.classList.add('active'); + setTimeout(() => { + button.classList.remove('active'); + }, 100); +} + +/** + * Binds a task to a hotkey keydown event + * @param {String} hotkey keyboard key + * @param {String} task task function name + */ +function bindHotkey(hotkey, task) { + var hotkeyFunctions = OHIF.viewer.hotkeyFunctions; + + // Only bind defined, non-empty HotKeys + if (!hotkey || hotkey === '') { + return; + } + + var fn; + if (task.indexOf('WLPreset') > -1) { + var presetName = task.replace('WLPreset', ''); + fn = function() { + WLPresets.applyWLPresetToActiveElement(presetName); + }; + } else { + fn = hotkeyFunctions[task]; + + // If the function doesn't exist in the + // hotkey function list, try the viewer-specific function list + if (!fn && OHIF.viewer && OHIF.viewer.functionList) { + fn = OHIF.viewer.functionList[task]; + } + } + + if (!fn) { + return; + } + + var hotKeyForBinding = hotkey.toLowerCase(); + + $(document).bind('keydown', hotKeyForBinding, fn); +} + +/** + * Binds all hotkeys keydown events to the tasks defined in + * OHIF.viewer.hotkeys or a given param + * @param {Object} hotkeys hotkey and task mapping (not required). If not given, uses OHIF.viewer.hotkeys + */ +function enableHotkeys(hotkeys) { + const viewerHotkeys = hotkeys || OHIF.viewer.hotkeys; + + $(document).unbind('keydown'); + + Object.keys(viewerHotkeys).forEach(function(task) { + const taskHotkeys = viewerHotkeys[task]; + if (!taskHotkeys || !taskHotkeys.length) { + return; + } + + if (taskHotkeys instanceof Array) { + taskHotkeys.forEach(function(hotkey) { + bindHotkey(hotkey, task); + }); + } else { + // taskHotkeys represents a single key + bindHotkey(taskHotkeys, task); + } + }); +} + +/** + * Export functions inside hotkeyUtils namespace. + */ + +const hotkeyUtils = { + setOHIFRefLines, /* @TODO: find a better place for this... */ + setOHIFHotkeys, + mergeHotkeys, + enableHotkeys +}; + +export { hotkeyUtils }; diff --git a/Packages/ohif-viewerbase/client/lib/imageViewerViewportData.js b/Packages/ohif-viewerbase/client/lib/imageViewerViewportData.js new file mode 100644 index 000000000..a6f8d35f1 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/imageViewerViewportData.js @@ -0,0 +1,7 @@ + +export const imageViewerViewportData = { + callbacks: {}, + extendData() { + // No-Op function... + } +}; diff --git a/Packages/ohif-viewerbase/client/lib/panelNavigation.js b/Packages/ohif-viewerbase/client/lib/panelNavigation.js new file mode 100644 index 000000000..64c89cd62 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/panelNavigation.js @@ -0,0 +1,53 @@ +import { Session } from 'meteor/session'; +import { $ } from 'meteor/jquery'; +import { OHIF } from 'meteor/ohif:core'; +import { setActiveViewport } from './setActiveViewport'; + +const loadPreviousActivePanel = () => { + OHIF.log.info('nextActivePanel'); + let currentIndex = Session.get('activeViewport'); + currentIndex--; + + const viewports = $('.imageViewerViewport'); + const numViewports = viewports.length; + if (currentIndex < 0) { + currentIndex = numViewports - 1; + } + + const element = viewports.get(currentIndex); + if (!element) { + return; + } + + setActiveViewport(element); +}; + +const loadNextActivePanel = () => { + OHIF.log.info('nextActivePanel'); + let currentIndex = Session.get('activeViewport'); + currentIndex++; + + const viewports = $('.imageViewerViewport'); + const numViewports = viewports.length; + if (currentIndex >= numViewports) { + currentIndex = 0; + } + + const element = viewports.get(currentIndex); + if (!element) { + return; + } + + setActiveViewport(element); +}; + +/** + * Export functions inside panelNavigation namespace. + */ + +const panelNavigation = { + loadPreviousActivePanel, + loadNextActivePanel +}; + +export { panelNavigation }; diff --git a/Packages/ohif-viewerbase/client/lib/seriesNavigation.js b/Packages/ohif-viewerbase/client/lib/seriesNavigation.js new file mode 100644 index 000000000..97b9311a9 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/seriesNavigation.js @@ -0,0 +1,91 @@ +import { Session } from 'meteor/session'; +import { OHIF } from 'meteor/ohif:core'; +// @TODO: import symbols into local scope: Studies and ViewerData + +const getStudyFromStudyInstanceUid = studyInstanceUid => { + // @TypeSafeStudies + return Studies.findBy({ studyInstanceUid }); +}; + +const getNumberOfStacks = studyInstanceUid => { + // May not work if the Study contains non-image series? + const study = getStudyFromStudyInstanceUid(studyInstanceUid); + + if (!study || !study.series || !study.series.length) { + return; + } + + return study.series.length; +}; + +const getActiveViewportIndex = () => { + return Session.get('activeViewport'); +}; + +const loadSeries = indexCalculator => { + if(typeof indexCalculator !== 'function') { + return; + } + + const viewportIndex = getActiveViewportIndex(); + const contentId = Session.get('activeContentId'); + const viewerData = ViewerData[contentId].loadedSeriesData[viewportIndex]; + + if(!viewerData && !viewerData.studyInstanceUid) { + return; + } + + const { studyInstanceUid, seriesInstanceUid, displaySetInstanceUid } = viewerData; + + if(!studyInstanceUid) { + return; + } + + const numberOfStacks = getNumberOfStacks(studyInstanceUid); + if (!numberOfStacks || numberOfStacks === 1) { + return; + } + + const study = getStudyFromStudyInstanceUid(studyInstanceUid); + + const displaySetInstanceUids = study.series.map(series => { + return series.displaySetInstanceUid; + }); + + const currentLoadedStackIndex = displaySetInstanceUids.indexOf(displaySetInstanceUid); + + const newStackIndex = indexCalculator(currentLoadedStackIndex, numberOfStacks); + if (currentLoadedStackIndex === newStackIndex) { + return; + } + + const newDisplaySetInstanceUid = displaySetInstanceUids[newStackIndex]; + + const data = { + displaySetInstanceUid: newDisplaySetInstanceUid, + viewportIndex, + studyInstanceUid, + seriesInstanceUid + }; + + OHIF.viewerbase.layoutManager.rerenderViewportWithNewDisplaySet(viewportIndex, data); +}; + +const loadPreviousSeries = () => { + const indexCalculator = currentLoadedStackIndex => Math.max(currentLoadedStackIndex - 1, 0); + + loadSeries(indexCalculator); +}; + +const loadNextSeries = () => { + const indexCalculator = (currentLoadedStackIndex, numberOfStacks) => Math.min(currentLoadedStackIndex + 1, numberOfStacks - 1); + + loadSeries(indexCalculator); +}; + +const seriesNavigation = { + loadPreviousSeries, + loadNextSeries +}; + +export { seriesNavigation }; diff --git a/Packages/ohif-viewerbase/client/lib/sortingManager.js b/Packages/ohif-viewerbase/client/lib/sortingManager.js new file mode 100644 index 000000000..355e4507e --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/sortingManager.js @@ -0,0 +1,13 @@ +import { getSortedData } from './getSortedData'; + +const getDisplaySets = (studyMetadata, seriesNumber, iteratorFunction) => { + const iteratorFn = typeof iteratorFunction !== 'function' ? getSortedData : iteratorFunction; + + return iteratorFn(studyMetadata, seriesNumber); +}; + +const sortingManager = { + getDisplaySets +}; + +export { sortingManager }; \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/thumbnailDragHandlers.js b/Packages/ohif-viewerbase/client/lib/thumbnailDragHandlers.js new file mode 100644 index 000000000..5e563ae27 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/thumbnailDragHandlers.js @@ -0,0 +1,220 @@ +import { $ } from 'meteor/jquery'; +import { OHIF } from 'meteor/ohif:core'; + +const cloneElement = (element, targetId) => { + // Clone the DOM element + const clone = element.cloneNode(true); + + // Find any canvas children to clone + const clonedCanvases = $(clone).find('canvas'); + clonedCanvases.each((canvasIndex, clonedCanvas) => { + // Draw from the original canvas to the cloned canvas + const context = clonedCanvas.getContext('2d'); + const thumbnailCanvas = $(element).find('canvas').get(canvasIndex); + context.drawImage(thumbnailCanvas, 0, 0); + }); + + // Update the clone with the targetId + clone.id = targetId; + clone.style.visibility = 'hidden'; + + return clone; +}; + +const thumbnailDragStartHandler = (event, data) => { + // Prevent any scrolling behaviour normally caused by the original event + event.originalEvent.preventDefault(); + + // Identify the current study and series index from the thumbnail's DOM position + const targetThumbnail = event.currentTarget; + const $imageThumbnail = $(targetThumbnail); + + // Clone the image thumbnail + const targetId = 'DragClone'; + const clone = cloneElement(targetThumbnail, targetId); + const $clone = $(clone); + $clone.addClass('imageThumbnailClone'); + + // Set pointerEvents to pass through the clone DOM element + // This is necessary in order to identify what is below it + // when using document.elementFromPoint + clone.style.pointerEvents = 'none'; + + // Append the clone to the body + document.body.appendChild(clone); + + // Set the cursor x and y positions from the current touch/mouse coordinates + let cursorX; + let cursorY; + // Handle touchStart cases + if (event.type === 'touchstart') { + cursorX = event.originalEvent.touches[0].pageX; + cursorY = event.originalEvent.touches[0].pageY; + } + else { + cursorX = event.pageX; + cursorY = event.pageY; + + // Also hook up event handlers for mouse events + const handlers = {}; + handlers.mousemove = event => thumbnailDragHandler(event); + handlers.mouseup = event => thumbnailDragEndHandler(event, data, handlers); + + $(document).on('mousemove', handlers.mousemove); + $(document).on('mouseup', handlers.mouseup); + } + + // This block gets the current offset of the touch/mouse + // relative to the window + // + // i.e. Where did the user grab it from? + const offset = $imageThumbnail.offset(); + const { left, top } = offset; + + // This difference is saved for later so the element movement looks normal + const diff = { + x: cursorX - left, + y: cursorY - top + }; + $clone.data('diff', diff); + + // This sets the default style properties of the cloned element so it is + // ready to be dragged around the page + $clone.css({ + left: cursorX - diff.x, + position: 'fixed', + top: cursorY - diff.y, + visibility: 'hidden', + 'z-index': 100000 + }); +}; + +const thumbnailDragHandler = event => { + // Get the touch/mouse coordinates from the event + let cursorX; + let cursorY; + if (event.type === 'touchmove') { + cursorX = event.originalEvent.changedTouches[0].pageX; + cursorY = event.originalEvent.changedTouches[0].pageY; + } + else { + cursorX = event.pageX; + cursorY = event.pageY; + } + + // Find the clone element and update it's position on the page + const $clone = $('#DragClone'); + const diff = $clone.data('diff'); + $clone.css({ + left: cursorX - diff.x, + position: 'fixed', + top: cursorY - diff.y, + visibility: 'visible', + 'z-index': 100000 + }); + + // Identify the element below the current cursor position + const elemBelow = document.elementFromPoint(cursorX, cursorY); + + // If none exists, stop here + if (!elemBelow) { + return; + } + + // Remove any current faded effects on viewports + $('.imageViewerViewport canvas').removeClass('faded'); + + // Figure out what to do depending on what we're dragging over + const $viewportsDraggedOver = $(elemBelow).parents('.imageViewerViewport'); + if ($viewportsDraggedOver.length) { + // If we're dragging over a non-empty viewport, fade it and change the cursor style + $viewportsDraggedOver.find('canvas').not('.magnifyTool').addClass('faded'); + document.body.style.cursor = 'copy'; + } + else if (elemBelow.classList.contains('imageViewerViewport') && elemBelow.classList.contains('empty')) { + // If we're dragging over an empty viewport, just change the cursor style + document.body.style.cursor = 'copy'; + } + else { + // Otherwise, keep the cursor as no-drop style + document.body.style.cursor = 'no-drop'; + } +}; + +const thumbnailDragEndHandler = (event, data, handlers) => { + // Remove the mouse event listeners + if (handlers) { + $(document).off('mousemove', handlers.mousemove); + $(document).off('mouseup', handlers.mouseup); + } + + // Reset the cursor style to the default + document.body.style.cursor = 'auto'; + + // Get the cloned element + const $clone = $('#DragClone'); + + // If it doesn't exist, stop here + if (!$clone.length) { + return; + } + + const offset = $clone.offset(); + const { top, left } = offset; + const diff = $clone.data('diff'); + + // Identify the element below the cloned element position + const elemBelow = document.elementFromPoint(left + diff.x, top + diff.y); + + // Remove all cloned elements from the page + $('.imageThumbnailClone').remove(); + + // Remove any current faded effects on viewports + $('.imageViewerViewport canvas').removeClass('faded'); + + // If none exists, stop here + if (!elemBelow) { + return; + } + + // Remove any fade effects on the element below + elemBelow.classList.remove('faded'); + + let element; + const $viewportsDraggedOver = $(elemBelow).closest('.imageViewerViewport'); + + if ($viewportsDraggedOver.length) { + // If we're dragging over a non-empty viewport, retrieve it + element = $viewportsDraggedOver.get(0); + } + else if (elemBelow.classList.contains('imageViewerViewport') && + elemBelow.classList.contains('empty')) { + // If we're dragging over an empty viewport, retrieve that instead + element = elemBelow; + } + else { + // Otherwise, stop here + return false; + } + + // If there is no stored drag and drop data, stop here + if (!data) { + return false; + } + + // Get the dropped viewport index + const viewportIndex = $('.imageViewerViewport').index(element); + + // Rerender the viewport using the dragged thumbnail data + OHIF.viewerbase.layoutManager.rerenderViewportWithNewDisplaySet(viewportIndex, data); + + return false; +}; + +const thumbnailDragHandlers = { + thumbnailDragEndHandler, + thumbnailDragStartHandler, + thumbnailDragHandler +}; + +export { thumbnailDragHandlers }; \ No newline at end of file diff --git a/Packages/ohif-viewerbase/client/lib/updateCrosshairsSynchronizer.js b/Packages/ohif-viewerbase/client/lib/updateCrosshairsSynchronizer.js new file mode 100644 index 000000000..d4f8bc4d8 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/updateCrosshairsSynchronizer.js @@ -0,0 +1,36 @@ +import { $ } from 'meteor/jquery'; +import { getFrameOfReferenceUID } from './getFrameOfReferenceUID'; +import { crosshairsSynchronizers } from './crosshairsSynchronizers'; + +/** + * This function is used to maintain the updateImageSynchronizers + * that are using in the Crosshair tool. The function creates + * (and destroys any currently existing) a new synchronizer for the given + * frame of reference. It then searches for other viewports that share the same + * frame of reference, and adds those to the synchronizer. These viewports + * will now function together when the Crosshair tool is used. + * + * @param currentFrameOfReferenceUID + */ + export function updateCrosshairsSynchronizer(currentFrameOfReferenceUID) { + // Check if an old synchronizer exists, and if it does, destroy it + // If not, create a new one + let synchronizer = crosshairsSynchronizers.synchronizers[currentFrameOfReferenceUID]; + if (synchronizer) { + // If it already exists, remove all source & target elements + synchronizer.destroy(); + } else { + // Create a new synchronizer + crosshairsSynchronizers.synchronizers[currentFrameOfReferenceUID] = new cornerstoneTools.Synchronizer("CornerstoneNewImage", cornerstoneTools.updateImageSynchronizer); + synchronizer = crosshairsSynchronizers.synchronizers[currentFrameOfReferenceUID]; + } + + // Add all elements that stem from the same frame of reference + $('.imageViewerViewport').each((index, element) => { + const frameOfReferenceUID = getFrameOfReferenceUID(element); + if (currentFrameOfReferenceUID !== frameOfReferenceUID) { + return; + } + synchronizer.add(element); + }); +} diff --git a/Packages/ohif-viewerbase/client/lib/updateOrientationMarkers.js b/Packages/ohif-viewerbase/client/lib/updateOrientationMarkers.js new file mode 100644 index 000000000..c2ad8e0a7 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/updateOrientationMarkers.js @@ -0,0 +1,65 @@ +import { $ } from 'meteor/jquery'; + +/** + * Updates the orientation labels on a Cornerstone-enabled Viewport element + * when the viewport settings change (e.g. when a horizontal flip or a rotation occurs) + * + * @param element The DOM element of the Cornerstone viewport + * optional + * @param viewport The current viewport + */ +export function updateOrientationMarkers(element, viewport) { + // Get the current viewport settings + if(!viewport) { + viewport = cornerstone.getViewport(element); + } + + // Updates the orientation labels on the viewport + const enabledElement = cornerstone.getEnabledElement(element); + const imagePlane = cornerstoneTools.metaData.get('imagePlane', enabledElement.image.imageId); + + if (!imagePlane || !imagePlane.rowCosines || !imagePlane.columnCosines) { + return; + } + + const rowString = cornerstoneTools.orientation.getOrientationString(imagePlane.rowCosines); + const columnString = cornerstoneTools.orientation.getOrientationString(imagePlane.columnCosines); + const oppositeRowString = cornerstoneTools.orientation.invertOrientationString(rowString); + const oppositeColumnString = cornerstoneTools.orientation.invertOrientationString(columnString); + + const markers = { + top: oppositeColumnString, + left: oppositeRowString + }; + + // If any vertical or horizontal flips are applied, change the orientation strings ahead of + // the rotation applications + if (viewport.vflip) { + markers.top = cornerstoneTools.orientation.invertOrientationString(markers.top); + } + + if (viewport.hflip) { + markers.left = cornerstoneTools.orientation.invertOrientationString(markers.left); + } + + // Get the viewport orientation marker DOM elements + const viewportOrientationMarkers = $(element).siblings('.viewportOrientationMarkers'); + const topMarker = viewportOrientationMarkers.find('.topMid'); + const leftMarker = viewportOrientationMarkers.find('.leftMid'); + + // Swap the labels accordingly if the viewport has been rotated + // This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees) + if (viewport.rotation === 90 || viewport.rotation === -270) { + topMarker.text(markers.left); + leftMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.top)); + } else if (viewport.rotation === -90 || viewport.rotation === 270) { + topMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.left)); + leftMarker.text(markers.top); + } else if (viewport.rotation === 180 || viewport.rotation === -180) { + topMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.top)); + leftMarker.text(cornerstoneTools.orientation.invertOrientationString(markers.left)); + } else { + topMarker.text(markers.top); + leftMarker.text(markers.left); + } +} diff --git a/Packages/ohif-viewerbase/client/lib/viewportOverlayUtils.js b/Packages/ohif-viewerbase/client/lib/viewportOverlayUtils.js new file mode 100644 index 000000000..811f5e7c8 --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/viewportOverlayUtils.js @@ -0,0 +1,100 @@ +import { getElementIfNotEmpty } from './getElementIfNotEmpty'; + +const getPatient = function(property) { + if (!this.imageId) { + return false; + } + + const patient = cornerstoneTools.metaData.get('patient', this.imageId); + if (!patient) { + return ''; + } + + return patient[property]; +}; + +const getStudy = function(property) { + if (!this.imageId) { + return false; + } + + const study = cornerstoneTools.metaData.get('study', this.imageId); + if (!study) { + return ''; + } + + return study[property]; +}; + +const getSeries = function(property) { + if (!this.imageId) { + return false; + } + + const series = cornerstoneTools.metaData.get('series', this.imageId); + if (!series) { + return ''; + } + + return series[property]; +}; + +const getInstance = function(property) { + if (!this.imageId) { + return false; + } + + const instance = cornerstoneTools.metaData.get('instance', this.imageId); + if (!instance) { + return ''; + } + + return instance[property]; +}; + +const getTagDisplay = function(property) { + if (!this.imageId) { + return false; + } + + const instance = cornerstoneTools.metaData.get('tagDisplay', this.imageId); + if (!instance) { + return ''; + } + + return instance[property]; +}; + +const getImage = function(viewportIndex) { + const element = getElementIfNotEmpty(viewportIndex); + if (!element) { + return false; + } + + let enabledElement; + try { + enabledElement = cornerstone.getEnabledElement(element); + } catch(error) { + return false; + } + + if (!enabledElement || !enabledElement.image) { + return false; + } + + return enabledElement.image; +}; + +const formatDateTime = (date, time) => `${date} ${time}`; + +const viewportOverlayUtils = { + getPatient, + getStudy, + getSeries, + getInstance, + getTagDisplay, + getImage, + formatDateTime +}; + +export { viewportOverlayUtils }; diff --git a/Packages/ohif-viewerbase/client/lib/viewportUtils.js b/Packages/ohif-viewerbase/client/lib/viewportUtils.js new file mode 100644 index 000000000..a6c9abeea --- /dev/null +++ b/Packages/ohif-viewerbase/client/lib/viewportUtils.js @@ -0,0 +1,268 @@ +import { Session } from 'meteor/session'; +import { Random } from 'meteor/random'; +import { $ } from 'meteor/jquery'; +import { _ } from 'meteor/underscore'; +// Local Modules +import { updateOrientationMarkers } from './updateOrientationMarkers'; +import { getInstanceClassDefaultViewport } from './instanceClassSpecificViewport'; + +function getActiveViewportElement() { + const viewportIndex = Session.get('activeViewport') || 0; + return $('.imageViewerViewport').get(viewportIndex); +} + +function zoomIn() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + let viewport = cornerstone.getViewport(element); + const scaleIncrement = 0.15; + const maximumScale = 10; + viewport.scale = Math.min(viewport.scale + scaleIncrement, maximumScale); + cornerstone.setViewport(element, viewport); +} + +function zoomOut() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + let viewport = cornerstone.getViewport(element); + const scaleIncrement = 0.15; + const minimumScale = 0.05; + viewport.scale = Math.max(viewport.scale - scaleIncrement, minimumScale); + cornerstone.setViewport(element, viewport); +} + +function zoomToFit() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + cornerstone.fitToWindow(element); +} + +function rotateL() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + let viewport = cornerstone.getViewport(element); + viewport.rotation -= 90; + cornerstone.setViewport(element, viewport); + updateOrientationMarkers(element, viewport); +} + +function rotateR() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + let viewport = cornerstone.getViewport(element); + viewport.rotation += 90; + cornerstone.setViewport(element, viewport); + updateOrientationMarkers(element, viewport); +} + +function invert() { + const element = getActiveViewportElement(); + if (!element) { + return; + } + + let viewport = cornerstone.getViewport(element); + viewport.invert = (viewport.invert === false); + cornerstone.setViewport(element, viewport); +} + +function flipV() { + const element = getActiveViewportElement(); + let viewport = cornerstone.getViewport(element); + viewport.vflip = (viewport.vflip === false); + cornerstone.setViewport(element, viewport); + updateOrientationMarkers(element, viewport); +} + +function flipH() { + const element = getActiveViewportElement(); + let viewport = cornerstone.getViewport(element); + viewport.hflip = (viewport.hflip === false); + cornerstone.setViewport(element, viewport); + updateOrientationMarkers(element, viewport); +} + +function resetViewport() { + const element = getActiveViewportElement(); + const enabledElement = cornerstone.getEnabledElement(element); + if (enabledElement.fitToWindow === false) { + const imageId = enabledElement.image.imageId; + const instance = cornerstoneTools.metaData.get('instance', imageId); + + enabledElement.viewport = cornerstone.getDefaultViewport(enabledElement.canvas, enabledElement.image); + + const instanceClassDefaultViewport = getInstanceClassDefaultViewport(instance, enabledElement, imageId); + cornerstone.setViewport(element, instanceClassDefaultViewport); + } else { + cornerstone.reset(element); + } +} + +function clearTools() { + const element = getActiveViewportElement(); + const toolStateManager = cornerstoneTools.globalImageIdSpecificToolStateManager; + toolStateManager.clear(element); + cornerstone.updateImage(element); +} + +// This function was originally defined alone inside client/lib/toggleDialog.js +// and has been moved here to avoid circular dependency issues. +function toggleDialog(element) { + const $element = $(element); + if($element.is('dialog')) { + if (element.hasAttribute('open')) { + stopAllClips(); + element.close(); + } else { + element.show(); + } + } + else { + const isClosed = $element.hasClass('dialog-open'); + $element.toggleClass('dialog-closed', isClosed); + $element.toggleClass('dialog-open', !isClosed); + } + + Session.set('UpdateCINE', Random.id()); +} + +// Toggle the play/stop state for the cornerstone clip tool +function toggleCinePlay() { + // Get the active viewport element + const element = getActiveViewportElement(); + + // Check if it's playing the clip to toggle it + if (isPlaying()) { + cornerstoneTools.stopClip(element); + } else { + cornerstoneTools.playClip(element); + } + + // Update the UpdateCINE session property + Session.set('UpdateCINE', Random.id()); +} + +// Show/hide the CINE dialog +function toggleCineDialog() { + var dialog = document.getElementById('cineDialog'); + toggleDialog(dialog); +} + +// Check if the clip is playing on the active viewport +function isPlaying() { + // Create a dependency on LayoutManagerUpdated and UpdateCINE session + Session.get('UpdateCINE'); + Session.get('LayoutManagerUpdated'); + + // Get the viewport element and its current playClip tool state + const element = getActiveViewportElement(); + // Empty Elements throws cornerstore exception + if (!element || !$(element).find('canvas').length) { + return; + } + + const toolState = cornerstoneTools.getToolState(element, 'playClip'); + + // Stop here if the tool state is not defined yet + if (!toolState) { + return false; + } + + // Get the clip state + const clipState = toolState.data[0]; + + if(clipState) { + // Return true if the clip is playing + return !_.isUndefined(clipState.intervalId); + } + + return false; +} + +// Check if a study has multiple frames +function hasMultipleFrames() { + // Its called everytime active viewport and/or layout change + Session.get('activeViewport'); + Session.get('LayoutManagerUpdated'); + + const activeViewport = getActiveViewportElement(); + + // No active viewport yet: disable button + if(!activeViewport || !$(activeViewport).find('canvas').length) { + return true; + } + + // Get images in the stack + const stackToolData = cornerstoneTools.getToolState(activeViewport, 'stack'); + + // No images in the stack, so disable button + if (!stackToolData || !stackToolData.data || !stackToolData.data.length) { + return true; + } + + // Get number of images in the stack + const stackData = stackToolData.data[0]; + const nImages = stackData.imageIds && stackData.imageIds.length ? stackData.imageIds.length : 1; + + // Stack has just one image, so disable button + if(nImages === 1) { + return true; + } + + return false; +} + +// Stop clips on all non-empty elements +function stopAllClips() { + const elements = $('.imageViewerViewport').not('.empty'); + elements.each( (index, element) => { + if ($(element).find('canvas').length) { + cornerstoneTools.stopClip(element); + } + }); +} + + +// Create an event listener to update playing state when a clip stops playing +$(window).on('CornerstoneToolsClipStopped', () => Session.set('UpdateCINE', Random.id())); + +/** + * Export functions inside viewportUtils namespace. + */ + +const viewportUtils = { + getActiveViewportElement, + zoomIn, + zoomOut, + zoomToFit, + rotateL, + rotateR, + invert, + flipV, + flipH, + resetViewport, + clearTools, + toggleDialog, + toggleCinePlay, + toggleCineDialog, + isPlaying, + hasMultipleFrames, + stopAllClips +}; + +export { viewportUtils }; diff --git a/Packages/ohif-viewerbase/main.js b/Packages/ohif-viewerbase/main.js new file mode 100644 index 000000000..1d43f543d --- /dev/null +++ b/Packages/ohif-viewerbase/main.js @@ -0,0 +1,39 @@ +/** + * Import namespace... + */ + +import { OHIF, Viewerbase } from './namespace.js'; + +/** + * Import scripts that will populate the Viewerbase namespace as a side effect only import. This is effectively the public API... + */ + +import './client/'; // which is actually: import './client/index.js'; + +/** + * Export relevant objects... + * + * With the following export it becomes possible to import "OHIF" from "ohif:core" and "Viewerbase" + * from "ohif:viewerbase" using a single import (a shorthand), like this: + * + * import { OHIF } from 'meteor/ohif:viewerbase'; + * + * Which is equivalent to: + * + * import { OHIF } from 'meteor/ohif:core'; + * import 'meteor/ohif:viewerbase'; + * + * The second (extended) format should be used when other OHIF packages are also to be used within + * the current module. This makes it explicit that the following imports will populate their + * respective namespaces within the to "OHIF" namespace. Example: + * + * import { OHIF } from 'meteor/ohif:core'; + * import 'meteor/ohif:viewerbase'; + * import 'meteor/ohif:hanging-protocols'; + * [ ... ] + * OHIF.viewerbase.setActiveViewport(...); + * OHIF.hangingprotocols.doSomething(...); + * + */ + +export { OHIF, Viewerbase }; diff --git a/Packages/ohif-viewerbase/namespace.js b/Packages/ohif-viewerbase/namespace.js new file mode 100644 index 000000000..6f4174385 --- /dev/null +++ b/Packages/ohif-viewerbase/namespace.js @@ -0,0 +1,23 @@ +/** + * Import main dependency... + */ + +import { OHIF } from 'meteor/ohif:core'; + +/** + * Create Viewerbase namespace... + */ + +const Viewerbase = {}; + +/** + * Append Viewerbase namespace to OHIF namespace... + */ + +OHIF.viewerbase = Viewerbase; + +/** + * Export relevant objects... + */ + +export { OHIF, Viewerbase };