This commit is contained in:
Rob Lewis 2016-08-17 16:31:15 -04:00
commit 4e164215ea
56 changed files with 955 additions and 262 deletions

View File

@ -12,6 +12,9 @@
<meta name="HandheldFriendly" content="True"> <meta name="HandheldFriendly" content="True">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<!-- Make SVG work on IE -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link href='http://fonts.googleapis.com/css?family=Roboto:400,100,100italic,300,300italic,400italic,500,500italic,700,700italic,900,900italic&subset=latin,latin-ext' rel='stylesheet' type='text/css'> <link href='http://fonts.googleapis.com/css?family=Roboto:400,100,100italic,300,300italic,400italic,500,500italic,700,700italic,900,900italic&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
<link href='http://fonts.googleapis.com/css?family=Sanchez:400,700&subset=latin,latin-ext' rel='stylesheet' type='text/css'> <link href='http://fonts.googleapis.com/css?family=Sanchez:400,700&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
</head> </head>

View File

@ -12,7 +12,7 @@ Template.toolbarSection.helpers({
svgLink: '/packages/viewerbase/assets/icons.svg#icon-studies', svgLink: '/packages/viewerbase/assets/icons.svg#icon-studies',
svgWidth: 15, svgWidth: 15,
svgHeight: 13, svgHeight: 13,
bottomLabel: 'Studies' bottomLabel: 'Series'
}] }]
}; };
}, },

View File

@ -1,3 +1,4 @@
@import "{design}/styles/imports/animations"
@import "{design}/styles/imports/mixins" @import "{design}/styles/imports/mixins"
@import "{design}/styles/imports/spacings" @import "{design}/styles/imports/spacings"
@import "{design}/styles/imports/variables" @import "{design}/styles/imports/variables"

View File

@ -26,6 +26,7 @@ Package.onUse(function(api) {
// Common styles // Common styles
api.addFiles([ api.addFiles([
'styles/common/webfonts.styl', 'styles/common/webfonts.styl',
'styles/common/keyframes.styl',
'styles/common/global.styl', 'styles/common/global.styl',
'styles/common/spacings.styl' 'styles/common/spacings.styl'
], 'client'); ], 'client');

View File

@ -0,0 +1,19 @@
@import "{design}/app.styl"
@keyframes zoomIn
from
transform(scale(0))
to
transform(scale(1))
@keyframes fadeIn
from
opacity: 0
to
opacity: 1
@keyframes fadeOut
from
opacity: 1
to
opacity: 0

View File

@ -13,8 +13,8 @@ $gray6 = #303030
.tree-content .tree-content
background-color: white background-color: white
position: relative position: relative
transition(transform 0.3s ease\, background-color 0.3s ease\, border-color 0.3s ease\, border-radius 0.3s ease)
&
.tree-search .tree-search
a.tree-back a.tree-back
.tree-breadcrumb span .tree-breadcrumb span
@ -146,6 +146,7 @@ $gray6 = #303030
&.active &.active
box-shadow: 0 0 0 200px white, inset 0 0 0 200px white box-shadow: 0 0 0 200px white, inset 0 0 0 200px white
position: fixed
transition(box-shadow 0.3s ease) transition(box-shadow 0.3s ease)
z-index: 0 z-index: 0
@ -192,7 +193,6 @@ $gray6 = #303030
&>.tree-content &>.tree-content
transform(scale(0)) transform(scale(0))
transform-origin(100% 50%) transform-origin(100% 50%)
transition(all 0.3s ease)
.select-tree-common .select-tree-common
overflow: hidden overflow: hidden

View File

@ -0,0 +1,17 @@
animationDefaults()
animation-duration: 0.3s
animation-direction: alternate
animation-timing-function: ease-out
animation-fill-mode: forwards
animateZoomIn()
animationDefaults()
animation-name: zoomIn
animateFadeIn()
animationDefaults()
animation-name: fadeIn
animateFadeOut()
animationDefaults()
animation-name: fadeOut

View File

@ -12,15 +12,18 @@ var getInstanceRetrievalParams = function(studyInstanceUID, seriesInstanceUID) {
return { return {
0x0020000D: studyInstanceUID ? studyInstanceUID : '', 0x0020000D: studyInstanceUID ? studyInstanceUID : '',
0x0020000E: (studyInstanceUID && seriesInstanceUID) ? seriesInstanceUID : '', 0x0020000E: (studyInstanceUID && seriesInstanceUID) ? seriesInstanceUID : '',
0x00080005: '', 0x00080005: '', // specificCharacterSet
0x00080020: '', 0x00080020: '', // studyDate
0x00080030: '', 0x00080030: '', // studyDescription
0x00080090: '', 0x00080090: '', // referringPhysicianName
0x00100010: '', 0x00100010: '', // patientName
0x00100020: '', 0x00100020: '', // patientId
0x00200010: '', 0x00100030: '', // patientBirthDate
0x0008103E: '', 0x00100040: '', // patientSex
0x00200011: '', 0x00200010: '', // studyId
0x0008103E: '', // seriesDescription
0x00200011: '', // seriesNumber
0x00080080: '', // institutionName
0x00080016: '', // sopClassUid 0x00080016: '', // sopClassUid
0x00080018: '', // sopInstanceUid 0x00080018: '', // sopInstanceUid
0x00080060: '', // modality 0x00080060: '', // modality
@ -37,7 +40,9 @@ var getInstanceRetrievalParams = function(studyInstanceUID, seriesInstanceUID) {
0x00281052: '', // rescaleIntercept 0x00281052: '', // rescaleIntercept
0x00281053: '', // rescaleSlope 0x00281053: '', // rescaleSlope
0x00280002: '', // samplesPerPixel 0x00280002: '', // samplesPerPixel
0x00180050: '', // sliceThickness
0x00201041: '', // sliceLocation 0x00201041: '', // sliceLocation
0x00189327: '', // tablePosition
0x00281050: '', // windowCenter 0x00281050: '', // windowCenter
0x00281051: '', // windowWidth 0x00281051: '', // windowWidth
0x00280030: '', // pixelSpacing 0x00280030: '', // pixelSpacing
@ -47,9 +52,14 @@ var getInstanceRetrievalParams = function(studyInstanceUID, seriesInstanceUID) {
0x00200032: '', // imagePositionPatient 0x00200032: '', // imagePositionPatient
0x00200037: '', // imageOrientationPatient 0x00200037: '', // imageOrientationPatient
0x00200052: '', // frameOfReferenceUID 0x00200052: '', // frameOfReferenceUID
0x00282110: '', // lossyImageCompression
0x00282112: '', // lossyImageCompressionRatio
0x00282114: '', // lossyImageCompressionMethod,
0x00180088: '' // spacingBetweenSlices
// Orthanc has a bug here so we can't retrieve sequences at the moment // Orthanc has a bug here so we can't retrieve sequences at the moment
// https://groups.google.com/forum/#!topic/orthanc-users/ghKJfvtnK8Y // https://groups.google.com/forum/#!topic/orthanc-users/ghKJfvtnK8Y
//0x00282111: '', // derivationDescription
//0x00082112: '' // sourceImageSequence //0x00082112: '' // sourceImageSequence
}; };
}; };

View File

@ -0,0 +1,102 @@
const hasValueAtTimepoint = timepointId => {
return measurement => {
if (measurement.timepoints[timepointId]) {
return true;
}
};
};
const hasNoValueAtTimepoint = timepointId => {
return measurement => {
if (measurement.timepoints[timepointId] === undefined) {
return true;
}
};
};
export const MeasurementApi = {
sortOptions: {
sort: {
lesionNumberAbsolute: 1
}
},
// Return all Measurements
all(withPriors=false) {
let data = Measurements.find({}, this.sortOptions).fetch();
// If we don't have a prior for this Timepoint,
// this filter, we should just return all of the
// available Non-Targets measurements
if (this.priorTimepointId && withPriors === true) {
return data.filter(hasValueAtTimepoint(this.priorTimepointId))
}
return data;
},
unmarked() {
const withPriors = true;
return this.all(withPriors).filter(hasNoValueAtTimepoint(this.currentTimepointId));;
},
unmarkedTargets() {
const withPriors = true;
return this.targets(withPriors).filter(hasNoValueAtTimepoint(this.currentTimepointId));;
},
unmarkedNonTargets() {
const withPriors = true;
return this.nonTargets(withPriors).filter(hasNoValueAtTimepoint(this.currentTimepointId));;
},
// Return only Target Measurements
targets(withPriors=false) {
let data = Measurements.find({
isTarget: true
}, this.sortOptions).fetch();
// If we don't have a prior for this Timepoint,
// this filter, we should just return all of the
// available Non-Targets measurements
if (this.priorTimepointId && withPriors === true) {
return data.filter(hasValueAtTimepoint(this.priorTimepointId))
}
return data;
},
// Return only Non-Target Measurements
nonTargets(withPriors=false) {
let data = Measurements.find({
isTarget: false
}, this.sortOptions).fetch();
// If we don't have a prior for this Timepoint,
// this filter, we should just return all of the
// available Non-Targets measurements
if (this.priorTimepointId && withPriors === true) {
return data.filter(hasValueAtTimepoint(this.priorTimepointId))
}
return data;
},
// Return only New Lesions
newLesions() {
// If we are current editing a Baseline we won't have any priors, so newLesions
// should return an empty array.
if (!this.priorTimepointId) {
return [];
}
// Find only lesions that have no value at the previous timepoint
return this.all().filter(hasNoValueAtTimepoint(this.priorTimepointId));
},
firstLesion() {
return Measurements.findOne({
target: true
}, this.sortOptions);
}
};

View File

@ -19,12 +19,42 @@ class TimepointApi {
return this.timepoints.find().fetch(); return this.timepoints.find().fetch();
} }
// Return only the current and prior timepoints // Return only the current timepoint
latest() { current() {
const options = { return this.timepoints.findOne({
limit: 2 timepointId: this.currentTimepointId
}; });
return this.timepoints.find({}, options).fetch(); }
prior() {
const latestDate = this.current().latestDate;
return this.timepoints.findOne({
latestDate: {
$lt: latestDate
}
}, {
sort: {
latestDate: -1
},
});
}
// Return only the current and prior Timepoints
currentAndPrior() {
let timepoints = [this.current()];
const prior = this.prior();
if (prior) {
timepoints.push(prior);
}
return timepoints;
}
// Return only the baseline timepoint
baseline() {
return this.timepoints.findOne({
timepointType: 'baseline'
});
} }
// Return only the key timepoints (current, prior, nadir and baseline) // Return only the key timepoints (current, prior, nadir and baseline)

View File

@ -1,3 +1,5 @@
import { MeasurementApi } from 'meteor/lesiontracker/client/api/measurement';
Template.caseProgress.onCreated(() => { Template.caseProgress.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
@ -5,41 +7,31 @@ Template.caseProgress.onCreated(() => {
instance.progressText = new ReactiveVar(); instance.progressText = new ReactiveVar();
instance.isLocked = new ReactiveVar(); instance.isLocked = new ReactiveVar();
if (!instance.data.currentTimepointId) { const current = instance.data.timepointApi.current();
if (!current.timepointId) {
console.warn('Case has no timepointId'); console.warn('Case has no timepointId');
return; return;
} }
const currentTimepointId = instance.data.currentTimepointId; instance.isLocked.set(current.isLocked);
const timepoint = Timepoints.findOne({
timepointId: currentTimepointId
});
const timepointType = timepoint.timepointType; // Retrieve the initial number of targets left to measure at this
// follow-up. Note that this is done outside of the reactive function
// below so that new lesions don't change the initial target count.
const withPriors = true;
const totalTargets = MeasurementApi.targets(withPriors).length;
instance.isLocked.set(timepoint.isLocked); // If we're currently reviewing a Baseline timepoint, don't do any
// progress measurement.
if (timepointType === 'baseline') { if (current.timepointType === 'baseline') {
instance.progressPercent.set(100); instance.progressPercent.set(100);
} else { } else {
// Retrieve the initial number of targets left to measure at this
// follow-up. Note that this is done outside of the reactive function
// below so that new lesions don't change the initial target count.
const totalTargets = Measurements.find({
isTarget: true
}).count();
// Setup a reactive function to update the progress whenever // Setup a reactive function to update the progress whenever
// a measurement is made // a measurement is made
instance.autorun(() => { instance.autorun(() => {
// Obtain the number of Measurements for which the current Timepoint has // Obtain the number of Measurements for which the current Timepoint has
// no Measurement data // no Measurement data
let numRemainingMeasurements = 0; const numRemainingMeasurements = MeasurementApi.unmarked().length;
Measurements.find().forEach(measurement => {
if (!measurement.timepoints[currentTimepointId]) {
numRemainingMeasurements++;
}
});
// Update the Case Progress text with the remaining measurement count // Update the Case Progress text with the remaining measurement count
instance.progressText.set(numRemainingMeasurements); instance.progressText.set(numRemainingMeasurements);
@ -67,7 +59,7 @@ Template.caseProgress.helpers({
}, },
progressComplete() { progressComplete() {
let progressPercent = Template.instance().progressPercent.get(); const progressPercent = Template.instance().progressPercent.get();
return progressPercent === 100; return progressPercent === 100;
} }
}); });

View File

@ -1,3 +1,5 @@
import { MeasurementApi } from 'meteor/lesiontracker/client/api/measurement';
Template.lesionTable.onCreated(() => { Template.lesionTable.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
@ -13,7 +15,7 @@ Template.lesionTable.onCreated(() => {
if (tableLayout === 'key') { if (tableLayout === 'key') {
timepoints = instance.data.timepointApi.key(); timepoints = instance.data.timepointApi.key();
} else { } else {
timepoints = instance.data.timepointApi.latest(); timepoints = instance.data.timepointApi.currentAndPrior();
} }
// Return key timepoints // Return key timepoints
@ -47,14 +49,10 @@ Session.setDefault('NewSeriesLoaded', false);
Template.lesionTable.onRendered(() => { Template.lesionTable.onRendered(() => {
// Find the first measurement by Lesion Number // Find the first measurement by Lesion Number
var firstLesion = Measurements.findOne({}, { const firstLesion = MeasurementApi.firstLesion();
sort: {
lesionNumber: 1
}
});
// Create an object to store the ContentId inside // Create an object to store the ContentId inside
var templateData = { const templateData = {
contentId: Session.get('activeContentId') contentId: Session.get('activeContentId')
}; };

View File

@ -1,7 +1,7 @@
Template.lesionTableHUD.onCreated(() => { Template.lesionTableHUD.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
instance.data.timepoints = new ReactiveVar(instance.data.timepointApi.latest()); instance.data.timepoints = new ReactiveVar(instance.data.timepointApi.currentAndPrior());
}); });
Template.lesionTableHUD.onRendered(() => { Template.lesionTableHUD.onRendered(() => {

View File

@ -1,14 +1,16 @@
<template name="lesionTableHeaderRow"> <template name="lesionTableHeaderRow">
<div class="lesionTableHeaderRow"> <div class="lesionTableHeaderRow">
{{ #if anyUnmarkedLesionsLeft}}
<div class="add js-setTool"> <div class="add js-setTool">
<svg> <svg>
<use xlink:href=/packages/viewerbase/assets/icons.svg#icon-ui-add></use> <use xlink:href=/packages/viewerbase/assets/icons.svg#icon-ui-add></use>
</svg> </svg>
</div> </div>
{{ /if }}
<div class="type"> <div class="type">
{{type}} {{type}}
</div> </div>
<div class="max"> <div class="max {{ # if gt numberOfLesions maxNumLesions }}warning{{/if}}">
{{ #if maxNumLesions }} {{ #if maxNumLesions }}
<p class="maxNumLesions"> <p class="maxNumLesions">
Max {{maxNumLesions}} Max {{maxNumLesions}}

View File

@ -1,3 +1,5 @@
import { MeasurementApi } from 'meteor/lesiontracker/client/api/measurement';
const toolTypesById = { const toolTypesById = {
target: 'bidirectional', target: 'bidirectional',
nonTarget: 'nonTarget', nonTarget: 'nonTarget',
@ -14,21 +16,14 @@ Template.lesionTableHeaderRow.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
instance.maxNumLesions = new ReactiveVar(); instance.maxNumLesions = new ReactiveVar();
const current = instance.data.timepointApi.current();
const timepointType = current.timepointType;
if (!instance.data.currentTimepointId) { if (!instance.data.currentTimepointId) {
console.warn('Case has no timepointId'); console.warn('Case has no timepointId');
return; return;
} }
// Give the header a proper name
instance.data.type = namesById[instance.data.id];
const currentTimepointId = instance.data.currentTimepointId;
const timepoint = Timepoints.findOne({
timepointId: currentTimepointId
});
const timepointType = timepoint.timepointType;
// TODO: Check if we have criteria where maximum limits are applied to // TODO: Check if we have criteria where maximum limits are applied to
// Non-Targets and/or New Lesions // Non-Targets and/or New Lesions
if (timepointType === 'baseline' && instance.data.id === 'target') { if (timepointType === 'baseline' && instance.data.id === 'target') {
@ -57,17 +52,35 @@ Template.lesionTableHeaderRow.onCreated(() => {
}); });
Template.lesionTableHeaderRow.helpers({ Template.lesionTableHeaderRow.helpers({
numberOfLesions: function() { type() {
const instance = Template.instance(); // Give the header a proper name
return instance.data.measurements.count(); const id = Template.instance().data.id;
return namesById[id];
}, },
maxNumLesions: function() {
numberOfLesions() {
return Template.instance().data.measurements.length;
},
maxNumLesions() {
return Template.instance().maxNumLesions.get(); return Template.instance().maxNumLesions.get();
},
anyUnmarkedLesionsLeft() {
const id = Template.instance().data.id;
if (id === 'target') {
return MeasurementApi.unmarkedTargets().length;
} else if (id === 'nonTarget') {
return MeasurementApi.unmarkedNonTargets().length;
}
// Keep the 'Add' button for the New Lesions header row
return true;
} }
}); });
Template.lesionTableHeaderRow.events({ Template.lesionTableHeaderRow.events({
'click .js-setTool': function(event, instance) { 'click .js-setTool'(event, instance) {
const id = instance.data.id; const id = instance.data.id;
const toolType = toolTypesById[id]; const toolType = toolTypesById[id];
toolManager.setActiveTool(toolType); toolManager.setActiveTool(toolType);

View File

@ -63,7 +63,7 @@ $headerRowHeight = 63px
text-align: right text-align: right
.maxNumLesions .maxNumLesions
background-color: $textSecondaryColor; background-color: $textSecondaryColor
border-radius: 3px border-radius: 3px
color: black color: black
display: table display: table
@ -75,3 +75,9 @@ $headerRowHeight = 63px
margin-top: 22px margin-top: 22px
padding: 2px 6px 0 padding: 2px 6px 0
text-transform: uppercase text-transform: uppercase
transition($sidebarTransition)
&.warning
.maxNumLesions
color: $textPrimaryColor
background-color: $uiYellow

View File

@ -1,17 +1,26 @@
<template name="lesionTableView"> <template name="lesionTableView">
<div class="lesionTableView scrollArea"> <div class="lesionTableView scrollArea">
{{>lesionTableHeaderRow id="target" measurements=targets currentTimepointId=currentTimepointId}} {{>lesionTableHeaderRow id="target"
measurements=targets
currentTimepointId=currentTimepointId
timepointApi=timepointApi}}
{{#each target in targets}} {{#each target in targets}}
{{>lesionTableRow (clone this rowItem=target)}} {{>lesionTableRow (clone this rowItem=target)}}
{{/each}} {{/each}}
{{>lesionTableHeaderRow id="nonTarget" measurements=nonTargets currentTimepointId=currentTimepointId}} {{>lesionTableHeaderRow id="nonTarget"
measurements=nonTargets
currentTimepointId=currentTimepointId
timepointApi=timepointApi}}
{{#each nonTarget in nonTargets}} {{#each nonTarget in nonTargets}}
{{>lesionTableRow (clone this rowItem=nonTarget)}} {{>lesionTableRow (clone this rowItem=nonTarget)}}
{{/each}} {{/each}}
{{#if gt this.timepoints.get.length 1}} {{#if gt this.timepoints.get.length 1}}
{{>lesionTableHeaderRow id="newLesion" measurements=newLesions currentTimepointId=currentTimepointId}} {{>lesionTableHeaderRow id="newLesion"
measurements=newLesions
currentTimepointId=currentTimepointId
timepointApi=timepointApi}}
{{#each newLesion in newLesions}} {{#each newLesion in newLesions}}
{{>lesionTableRow (clone this rowItem=newLesion)}} {{>lesionTableRow (clone this rowItem=newLesion)}}
{{/each}} {{/each}}

View File

@ -1,32 +1,23 @@
import { MeasurementApi } from 'meteor/lesiontracker/client/api/measurement';
Template.lesionTableView.helpers({ Template.lesionTableView.helpers({
targets() { targets() {
// All Targets shall be listed first followed by Non-Targets const withPriors = true;
return Measurements.find({ return MeasurementApi.targets(withPriors);
isTarget: true
}, {
sort: {
lesionNumberAbsolute: 1
}
});
}, },
nonTargets() { nonTargets() {
return Measurements.find({ const withPriors = true;
isTarget: false return MeasurementApi.nonTargets(withPriors);
}, {
sort: {
lesionNumberAbsolute: 1
}
});
}, },
newLesions() { newLesions() {
return Measurements.find({ return MeasurementApi.newLesions();
saved: false },
}, {
sort: { isFollowup() {
lesionNumberAbsolute: 1 const instance = Template.instance();
} const current = instance.data.timepointApi.current();
}); return (current && current.timepointType === 'followup');
} }
}); });

View File

@ -1,5 +1,5 @@
<template name="lesionTrackerViewportOverlay"> <template name="lesionTrackerViewportOverlay">
<div class="imageViewerViewportOverlay noselect"> <div class="imageViewerViewportOverlay noselect {{#if gt numImages 1}}controlsVisible{{/if}}">
<div class="topleft dicomTag"> <div class="topleft dicomTag">
<div>{{formatPN patientName}}</div> <div>{{formatPN patientName}}</div>
<div>{{patientId}}</div> <div>{{patientId}}</div>
@ -16,11 +16,13 @@
</div> </div>
<div class="bottomleft dicomTag"> <div class="bottomleft dicomTag">
<div>Ser: {{seriesNumber}}</div> <div>Ser: {{seriesNumber}}</div>
<div>Img: {{imageNumber}} ({{imageIndex}}/{{numImages}})</div> <div>{{#if gt numImages 1}}Img: {{instanceNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div>
<div>{{frameRate}}</div> <div>{{frameRate}}</div>
<div>{{imageDimensions}}</div> <div>{{imageDimensions}}</div>
<div>{{seriesDescription}}</div> <div>{{seriesDescription}}</div>
</div> </div>
{{>imageControls}} {{#if gt numImages 1}}
{{> imageControls imageIndex=imageIndex numImages=numImages}}
{{ /if }}
</div> </div>
</template> </template>

View File

@ -1,12 +1,27 @@
<template name="measureFlow"> <template name="measureFlow">
<div class="measure-flow"> <div class="measure-flow {{instance.state.get}}" style="position:fixed; top:200px; left:400px">
<button>Add label</button> <button class="btn-add">Add label</button>
{{>selectTree {{#if eq instance.state.get 'selected'}}
key='label' <div class="tree-leaf">
items=instance.items <span>{{instance.value.label}}</span>
label='Assign label' {{#if instance.descriptionEdit.get}}
searchPlaceholder='Search labels' <div class="description clearfix">
storageKey='measureLabelCommon' <textarea
}} class="pull-left"
rows="1"
style="{{#unless instance.description.get}}height:0{{/unless}}"
>{{instance.description.get}}</textarea>
</div>
{{else}}
{{#if instance.description.get}}
<div class="descriptionText">{{instance.description.get}}</div>
{{/if}}
{{/if}}
<div class="actions clearfix">
<button class="pull-left btn-rename">Rename</button>
<button class="pull-right btn-description">{{valueIf instance.description.get 'Edit' 'Add'}} description</button>
</div>
</div>
{{/if}}
</div> </div>
</template> </template>

View File

@ -1,6 +1,59 @@
import { OHIF } from 'meteor/ohif:core';
import { Template } from 'meteor/templating';
import { Blaze } from 'meteor/blaze';
import { ReactiveVar } from 'meteor/reactive-var';
import { _ } from 'meteor/underscore';
import { $ } from 'meteor/jquery';
Template.measureFlow.onCreated(() => { Template.measureFlow.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
instance.state = new ReactiveVar('closed');
instance.description = new ReactiveVar('');
instance.descriptionEdit = new ReactiveVar(false);
instance.items = [{
label: 'Category 1',
value: 'Category 1',
items: [{
label: 'Subcategory 1.1',
value: 'Subcategory 1.1'
}, {
label: 'Subcategory 1.2',
value: 'Subcategory 1.2',
items: [{
label: 'Subcategory 1.2.1',
value: 'Subcategory 1.2.1'
}, {
label: 'Subcategory 1.2.2',
value: 'Subcategory 1.2.2',
items: [{
label: 'Subcategory 1.2.2.1',
value: 'Subcategory 1.2.2.1'
}, {
label: 'Subcategory 1.2.2.2',
value: 'Subcategory 1.2.2.2'
}]
}]
}]
}, {
label: 'Category 2',
value: 'Category 2',
items: [{
label: 'Subcategory 2.1',
value: 'Subcategory 2.1'
}, {
label: 'Subcategory 2.2',
value: 'Subcategory 2.2'
}, {
label: 'Subcategory 2.3',
value: 'Subcategory 2.3'
}]
}, {
label: 'Category 3',
value: 'Category 3'
}];
const items = [ const items = [
'Adrenal', 'Adrenal',
'Bladder', 'Bladder',
@ -39,12 +92,139 @@ Template.measureFlow.onCreated(() => {
}); });
}); });
Template.measureFlow.onRendered(() => {
const instance = Template.instance();
// Make the measure flow bounded by the window borders
instance.$('.measure-flow').bounded();
});
Template.measureFlow.events({ Template.measureFlow.events({
'click .measure-flow .btn-add, click .measure-flow .btn-rename'(event, instance) {
// Set the open state for the component
instance.state.set('open');
// Wait template rerender before rendering the selectTree
Tracker.afterFlush(() => {
// Get the click position
const position = {
left: event.clientX,
top: event.clientY,
};
// Define the data for selectTreeComponent
const data = {
key: 'label',
items: instance.items,
label: 'Assign label',
searchPlaceholder: 'Search labels',
storageKey: 'measureLabelCommon',
position
};
// Define in which element the selectTree will be rendered in
const parentElement = instance.$('.measure-flow')[0];
// Render the selectTree element
instance.selectTreeView = Blaze.renderWithData(Template.selectTree, data, parentElement);
});
},
'click .measure-flow .btn-description'(event, instance) {
// Fade out the action buttons
instance.$('.measure-flow .actions').addClass('fadeOut');
// Set the description edit mode
instance.descriptionEdit.set(true);
// Wait for DOM re-rendering, resize and focus the description textarea
Tracker.afterFlush(() => {
const $textarea = instance.$('textarea');
$textarea.trigger('input').focus();
});
},
'input textarea, change textarea'(event, instance) {
const element = event.currentTarget;
const $element = $(element);
// Resize the textarea based on its content length
$element.css('max-height', 0);
$element.height(element.scrollHeight);
$element.css('max-height', '');
// Reposition the measure flow if needed
const $measureFlow = instance.$('.measure-flow');
$element.one('transitionend', () => $measureFlow.trigger('spatialChanged'));
},
'keydown textarea'(event, instance) {
// Unset the description edit mode if ENTER or ESC was pressed
if (event.which === 13 || event.which === 27) {
instance.$('.measure-flow .actions').removeClass('fadeOut');
instance.descriptionEdit.set(false);
}
// Keep the current description if ENTER was pressed
if (event.which === 13) {
instance.description.set($(event.currentTarget).val());
}
},
'click .select-tree-common label'(event, instance) {
// Set the common section clicked flag
instance.commonClicked = true;
},
'change .select-tree-root'(event, instance) {
// Stop here if it's an inner input event
if (event.target !== event.currentTarget) {
return;
}
// Store the selectTree component value before it's removed from DOM
const $treeRoot = $(event.currentTarget);
instance.value = $treeRoot.data('component').value();
},
'click .tree-leaf input'(event, instance) { 'click .tree-leaf input'(event, instance) {
console.warn('>>>>event', event); const $target = $(event.currentTarget);
const $label = $(event.currentTarget).closest('label'); const $label = $target.closest('label');
const $container = $label.closest('.select-tree-root').find('.tree-options:first'); const $treeRoot = $label.closest('.select-tree-root');
const top = $label.position().top; const $container = $treeRoot.find('.tree-options:first');
$container.scrollTop(top);
// Check if the targe click was a label inside common section
if (instance.commonClicked) {
const labelTop = $label.position().top;
const containerCenter = Math.round($container.height() / 2);
const labelCenter = Math.round($label.height() / 2);
// Scroll the options container to make the target input visible
$container.scrollTop(labelTop - containerCenter + labelCenter);
}
// Change the measure flow state to selected
instance.state.set('selected');
// Wait for the DOM re-rendering
Tracker.afterFlush(() => {
// Get the measure flow div
const $measureFlow = instance.$('.measure-flow');
// Get the clicked label window offset
const labelOffset = $label.offset();
// Subtract the label box shadow height from the label's position
labelOffset.top -= 10;
// Reposition the measure flow based on the clicked label position
$measureFlow.css(labelOffset);
// Resize the copied label with same width of the clicked one
$measureFlow.children('.tree-leaf').width($label.outerWidth());
});
// Wait the fade-out transition and remove the selectTree component
$container.one('transitionend', event => Blaze.remove(instance.selectTreeView));
} }
}); });

View File

@ -1,12 +1,122 @@
.measure-flow @import "{design}/app"
.select-tree-root>.tree-content>.tree-options .measure-flow
margin-right: -20px position: relative
max-height: 250px
overflow-x: hidden &.open,
overflow-y: scroll &.selected
position: relative
z-index: 2 .btn-add
opacity: 0
.btn-add
background-color: $activeColor
border: 2px solid $uiBorderColor
border-radius: 14px
color: $textColorActive
cursor: pointer
font-weight: bold
height: 24px
left: 0
line-height: 24px
opacity: 1
position: absolute
padding: 0 14px
top: 0
transition(opacity 0.3s ease)
white-space: nowrap
.select-tree-root>.tree-content
width: 213px
&>.tree-options
margin-right: -17px
max-height: 250px
overflow-x: hidden
overflow-y: scroll
position: relative
z-index: 2
.tree-inputs .tree-inputs
position: relative position: relative
&>.tree-leaf
position: relative
&:before
animateZoomIn()
background-color: $activeColor
border-radius: 20px
color: $uiGray
content: '\f00c'
display: block
font-family: FontAwesome
font-size: 24px
height: 40px
left: -46px
line-height: 40px
position: absolute
text-align: center
top: 3px
width: 40px
span
background: white
font-weight: normal
height: 46px
line-height: 46px
padding: 0 12px
input, span
display: none
.actions
margin-top: 16px
opacity: 0
&:not(.fadeOut)
animateFadeIn()
animation-delay: 0.3s
&.fadeOut
animateFadeOut()
button
background-color: transparent
border-radius: 16px
border: 1px solid $uiBorderColor
color: $textPrimaryColor
font-weight: normal
height: 31px
line-height: 31px
padding: 0 12px
text-align: center
.description
margin-top: -10px
margin-bottom: 26px
textarea
background-color: white
box-shadow: 0 10px white
border: 0
line-height: 20px
outline: none
overflow: hidden
padding: 0 12px
resize: none
transition(height 0.3s ease)
width: 100%
.descriptionText
background-color: white
box-shadow: 0 10px white
line-height: 20px
margin-bottom: 26px
margin-top: -10px
padding: 0 12px
&.selected>.tree-leaf
span
display: block

View File

@ -28,7 +28,7 @@ $circleSize = 46px
font-weight: 700 font-weight: 700
font-size: 17px font-size: 17px
text-align: center text-align: center
color: $textColorActive color: $activeColor
top: 0 top: 0
svg svg

View File

@ -9,7 +9,7 @@
</div> </div>
</div> </div>
<table class="table table-striped"> <table class="table table-responsive">
<thead> <thead>
<tr> <tr>
<th class="center">Include Study?</th> <th class="center">Include Study?</th>

View File

@ -5,15 +5,17 @@
{{>loadingText}} {{>loadingText}}
<div class="studyModality"> <div class="studyModality">
<div class="studyModalityBox"> <div class="studyModalityBox">
{{#if this.study.modalities}} <div class="studyModalityText" style="{{modalityStyle}}">
{{this.study.modalities}} {{#if this.study.modalities}}
{{else}} {{ modalities }}
UN {{else}}
{{/if}} UN
{{/if}}
</div>
</div> </div>
<div class="studyModalityText"> <div class="studyText">
<div class="studyModalityDate">{{formatDA this.study.studyDate 'D-MMM-YYYY'}}</div> <div class="studyDate">{{formatDA this.study.studyDate 'D-MMM-YYYY'}}</div>
<div class="studyModalityDescription">{{this.study.studyDescription}}</div> <div class="studyDescription">{{this.study.studyDescription}}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -34,6 +34,39 @@ Template.studyTimepointStudy.onRendered(() => {
} }
}); });
Template.studyTimepointStudy.helpers({
modalities() {
const instance = Template.instance();
let modalities = instance.data.study.modalities;
// Replace backslashes with spaces
return modalities.replace(/\\/g, ' ');
},
modalityStyle() {
// Responsively styles the Modality Acronyms for studies
// with more than one modality
const instance = Template.instance();
const modalities = instance.data.study.modalities;
const numModalities = modalities.split(/\\/g).length;
if (numModalities === 1) {
// If we have only one modality, it should take up the whole
// div.
return 'font-size: 1vw';
} else if (numModalities === 2) {
// If we have two, let them sit side-by-side
return 'font-size: 0.75vw';
} else {
// If we have more than two modalities, change the line
// height to display multiple rows, depending on the number
// of modalities we need to display.
const lineHeight = Math.ceil(numModalities / 2) * 1.2;
return 'line-height: ' + lineHeight + 'vh';
}
}
});
Template.studyTimepointStudy.events({ Template.studyTimepointStudy.events({
// Recalculates the timepoint height to make CSS transition smoother // Recalculates the timepoint height to make CSS transition smoother
'transitionend .studyTimepointThumbnails'(event, instance) { 'transitionend .studyTimepointThumbnails'(event, instance) {

View File

@ -80,29 +80,31 @@ $spacerY = 12px
padding: 0 padding: 0
text-align: center text-align: center
.studyModalityText .studyText
font-size: 13px font-size: 13px
left: ($spacerX * 3) + $boxWidth + ($nestingMargin * 3) left: ($spacerX * 3) + $boxWidth + ($nestingMargin * 3)
line-height: 14px line-height: 14px
position: absolute position: absolute
right: $spacerX right: $spacerX
top: $spacerY top: $spacerY
.studyModalityDate .studyDate
margin-top: 8px margin-top: 8px
color: $textSecondaryColor color: $textSecondaryColor
.studyModalityDescription .studyDescription
margin-top: 8px margin-top: 8px
color: $textPrimaryColor color: $textPrimaryColor
.studyModalityBox .studyModalityBox
color: $textSecondaryColor color: $textSecondaryColor
font-size: 22px
line-height: $boxWidth line-height: $boxWidth
margin-left: $nestingMargin * 2 margin-left: $nestingMargin * 2
margin-top: $nestingMargin * 2 margin-top: $nestingMargin * 2
position: relative position: relative
text-align: center
text-transform: uppercase .studyModalityText
text-align: center
text-transform: uppercase
height: 100%
& &
&:before &:before

View File

@ -133,19 +133,16 @@ Template.toolbarSection.helpers({
return buttonData; return buttonData;
} }
}); });
Template.toolbarSection.events({ Template.toolbarSection.events({
'click #toggleHUD'(event, instance) { 'click #toggleHUD'() {
const state = Session.get('lesionTableHudOpen'); const state = Session.get('lesionTableHudOpen');
Session.set('lesionTableHudOpen', !state); Session.set('lesionTableHudOpen', !state);
} }
}); });
Template.toolbarSection.onRendered(function() { Template.toolbarSection.onRendered(function() {
const instance = Template.instance();
// Set disabled/enabled tool buttons that are set in toolManager // Set disabled/enabled tool buttons that are set in toolManager
const states = toolManager.getToolDefaultStates(); const states = toolManager.getToolDefaultStates();
const disabledToolButtons = states.disabledToolButtons; const disabledToolButtons = states.disabledToolButtons;

View File

@ -5,7 +5,6 @@
height: 100% height: 100%
.dropdown-toggle .dropdown-toggle
background-color: $primaryBackgroundColor
color: $defaultColor color: $defaultColor
cursor: pointer cursor: pointer
font-size: 13px font-size: 13px

View File

@ -1,5 +1,6 @@
import { OHIF } from 'meteor/ohif:core'; import { OHIF } from 'meteor/ohif:core';
import { TimepointApi } from 'meteor/lesiontracker/lib/api/timepoint'; import { TimepointApi } from 'meteor/lesiontracker/client/api/timepoint';
import { MeasurementApi } from 'meteor/lesiontracker/client/api/measurement';
OHIF.viewer = OHIF.viewer || {}; OHIF.viewer = OHIF.viewer || {};
OHIF.viewer.loadIndicatorDelay = 3000; OHIF.viewer.loadIndicatorDelay = 3000;
@ -18,8 +19,6 @@ Session.setDefault('rightSidebar', null);
Template.viewer.onCreated(() => { Template.viewer.onCreated(() => {
const instance = Template.instance(); const instance = Template.instance();
instance.data.timepointApi = new TimepointApi();
ValidationErrors.remove({}); ValidationErrors.remove({});
instance.data.state = new ReactiveDict(); instance.data.state = new ReactiveDict();
@ -91,6 +90,17 @@ Template.viewer.onCreated(() => {
// Set buttons as enabled/disabled when Timepoints collection is ready // Set buttons as enabled/disabled when Timepoints collection is ready
timepointAutoCheck(dataContext); timepointAutoCheck(dataContext);
// Wait until the Timepoint subscription is ready to initialize the TimepointApi
instance.data.timepointApi = new TimepointApi();
instance.data.timepointApi.currentTimepointId = instance.data.currentTimepointId;
// Provide the necessary data to the Measurement API
MeasurementApi.currentTimepointId = instance.data.currentTimepointId;
const prior = instance.data.timepointApi.prior();
if (prior) {
MeasurementApi.priorTimepointId = prior.timepointId;
}
TrialResponseCriteria.validateAllDelayed(); TrialResponseCriteria.validateAllDelayed();
ViewerStudies.find().observe({ ViewerStudies.find().observe({

View File

@ -11,8 +11,9 @@ handleMeasurementAdded = function(e, eventData) {
TrialResponseCriteria.validateDelayed(measurementData); TrialResponseCriteria.validateDelayed(measurementData);
// Set reviewer for this timepoint // Set reviewer for this timepoint
if (measurementData.studyInstanceUid) { if (measurementData.studyInstanceUid) {
Meteor.call('setReviewer',measurementData.studyInstanceUid); Meteor.call('setReviewer', measurementData.studyInstanceUid);
} }
break; break;
case 'ellipticalRoi': case 'ellipticalRoi':
case 'length': case 'length':

View File

@ -18,7 +18,7 @@ handleMeasurementRemoved = function(e, eventData) {
} }
// Set reviewer for this timepoint // Set reviewer for this timepoint
if (measurementData.studyInstanceUid) { if (measurementData.studyInstanceUid) {
Meteor.call('setReviewer',measurementData.studyInstanceUid); Meteor.call('setReviewer', measurementData.studyInstanceUid);
} }
clearMeasurementTimepointData(measurement._id, measurementData.timepointId); clearMeasurementTimepointData(measurement._id, measurementData.timepointId);

View File

@ -265,7 +265,8 @@ Package.onUse(function(api) {
api.addFiles('client/components/serverInformation/serverInformationModal/serverInformationModal.js', 'client'); api.addFiles('client/components/serverInformation/serverInformationModal/serverInformationModal.js', 'client');
// API classes // API classes
api.addFiles('lib/api/timepoint.js'); api.addFiles('client/api/timepoint.js');
api.addFiles('client/api/measurement.js');
// Export global functions // Export global functions
api.export('pixelSpacingAutorunCheck', 'client'); api.export('pixelSpacingAutorunCheck', 'client');

View File

@ -15,6 +15,9 @@ OHIF.mixins.selectTree = new OHIF.Mixin({
const rootComponent = instance.data.root || component; const rootComponent = instance.data.root || component;
const rootInstance = rootComponent.templateInstance; const rootInstance = rootComponent.templateInstance;
// Store the component's current value
instance.currentValue = null;
// Create the component's value storage property // Create the component's value storage property
instance.data.currentNode = new ReactiveVar(null); instance.data.currentNode = new ReactiveVar(null);
@ -25,7 +28,23 @@ OHIF.mixins.selectTree = new OHIF.Mixin({
component.value = value => { component.value = value => {
const isGet = _.isUndefined(value); const isGet = _.isUndefined(value);
// Return the current node value // Return the current value
if (isGet) {
return rootInstance.currentValue;
}
// Change the current value
rootInstance.currentValue = value;
// Trigger the change event
component.$element.trigger('change');
};
// Method to manipulate nodes
component.node = node => {
const isGet = _.isUndefined(node);
// Return the current node
if (isGet) { if (isGet) {
const currentNode = instance.data.currentNode.get(); const currentNode = instance.data.currentNode.get();
return currentNode ? currentNode.value : null; return currentNode ? currentNode.value : null;
@ -35,12 +54,12 @@ OHIF.mixins.selectTree = new OHIF.Mixin({
const items = component.templateInstance.data.items; const items = component.templateInstance.data.items;
// Get the node for the selected value // Get the node for the selected value
const node = _.findWhere(items, { const currentNode = _.findWhere(items, {
value: value value: node
}); });
// Change the current node // Change the current node
instance.data.currentNode.set(node); instance.data.currentNode.set(currentNode);
}; };
// Return plain data for all the leaves inside current node // Return plain data for all the leaves inside current node
@ -59,6 +78,14 @@ OHIF.mixins.selectTree = new OHIF.Mixin({
return recursiveSeek(instance.data.items); return recursiveSeek(instance.data.items);
}; };
},
onRendered() {
const instance = Template.instance();
const component = instance.component;
// Define the main element
component.$element = instance.$('.select-tree:first').first();
} }
} }
}); });

View File

@ -27,6 +27,7 @@
id=(concat this.storageKey '_' (encodeId item.value)) id=(concat this.storageKey '_' (encodeId item.value))
class=this.radioClass class=this.radioClass
name=this.key name=this.key
itemData=item
value=item.value value=item.value
label=item.label label=item.label
labelClass=(valueIf (isArray item.items) 'tree-node' 'tree-leaf') labelClass=(valueIf (isArray item.items) 'tree-node' 'tree-leaf')

View File

@ -27,8 +27,31 @@ Template.selectTree.onRendered(() => {
// Set the margin to display the common section // Set the margin to display the common section
$treeRoot.children('.tree-content').css('margin-right', $treeRoot.width()); $treeRoot.children('.tree-content').css('margin-right', $treeRoot.width());
// Start the component transitions // Make the component respect the window boundaries
$treeRoot.addClass('started'); $treeRoot.bounded();
// Check if the component will be rendered on a specific position
const position = instance.data.position;
if (component === rootComponent && position) {
// Get the dimensions and move it with the given position as its center
const width = $treeRoot.outerWidth();
const height = $treeRoot.outerHeight();
position.left -= Math.round(width / 2);
position.top -= Math.round(height / 2);
// Change the component's position and trigger the bounded event
$treeRoot.css(_.extend({}, position, {
position: 'fixed'
})).trigger('spatialChanged');
}
Meteor.defer(() => {
// Start the component transitions
$treeRoot.addClass('started');
// Focus the search box
$treeRoot.find('.tree-search input').focus();
});
// Update the component's viewport height // Update the component's viewport height
instance.updateHeight = searchTerm => { instance.updateHeight = searchTerm => {
@ -43,8 +66,9 @@ Template.selectTree.onRendered(() => {
height = rootInstance.$('.tree-options:last').data('height'); height = rootInstance.$('.tree-options:last').data('height');
} }
// Update the viewport's height // Update the viewport's height and trigger the bounded event
rootInstance.$('.tree-options:first').height(height); rootInstance.$('.tree-options:first').first().height(height)
.on('transitionend', () => $treeRoot.trigger('spatialChanged'));
}; };
// Update the opened node // Update the opened node
@ -110,23 +134,29 @@ Template.selectTree.events({
}, },
'input .tree-search input'(event, instance) { 'input .tree-search input'(event, instance) {
// Get the tree root
const $treeRoot = $(event.currentTarget).closest('.select-tree-root');
// Change the search term to update the tree items // Change the search term to update the tree items
instance.searchTerm.set($(event.currentTarget).val()); instance.searchTerm.set($(event.currentTarget).val());
// Change the component state
$treeRoot.addClass('interacted');
}, },
'change .select-tree:first>.tree-content>.tree-options>.tree-inputs>label>input'(event, instance) { 'change .select-tree:first>.tree-content>.tree-options>.tree-inputs>label>input'(event, instance) {
const component = instance.component; const component = instance.component;
const $target = $(event.target); const $target = $(event.currentTarget);
const $label = $target.closest('label'); const $label = $target.closest('label');
const eventComponent = $target.data('component'); const eventComponent = $target.data('component');
const rootComponent = instance.data.root || component; const rootComponent = instance.data.root || component;
const rootInstance = rootComponent.templateInstance; const rootInstance = rootComponent.templateInstance;
// Change the component's value // Change the component's node
component.value(eventComponent.value()); component.node(eventComponent.value());
// Unset the active leaf // Unset the active leaf
rootInstance.$('label').removeClass('active'); rootInstance.$('label').removeClass('active').css('width', '');
// Unset the selected state // Unset the selected state
instance.setSelected(false); instance.setSelected(false);
@ -135,7 +165,7 @@ Template.selectTree.events({
const isLeaf = $label.hasClass('tree-leaf'); const isLeaf = $label.hasClass('tree-leaf');
if (isLeaf) { if (isLeaf) {
// Change the active item // Change the active item
$label.addClass('active'); $label.css('width', $label.outerWidth()).addClass('active');
// Ckeck if there's a storageKey defined // Ckeck if there's a storageKey defined
const storageKey = instance.data.storageKey; const storageKey = instance.data.storageKey;
@ -151,6 +181,10 @@ Template.selectTree.events({
storedData[itemKey] = 1; storedData[itemKey] = 1;
} }
// Set the selected leaf value in the root component
const itemData = $target.data('component').templateInstance.data.itemData;
rootComponent.value(itemData);
// Updata the stored data with the new count // Updata the stored data with the new count
OHIF.user.setData(storageKey, storedData); OHIF.user.setData(storageKey, storedData);
} }
@ -201,6 +235,27 @@ Template.selectTree.events({
currentInstance = currentInstance.component.parent.templateInstance; currentInstance = currentInstance.component.parent.templateInstance;
} }
} }
},
'click .select-tree-common label'(event, instance) {
// Get the clicked label
const $target = $(event.currentTarget);
// Build the input selector based on the label target
const inputSelector = '#' + $target.attr('for');
// Check if the input is not rendered
if (!$(inputSelector).length) {
// Change the search terms with the clicked label string
instance.$('.tree-search input').val($target.text()).trigger('input');
// Wait for options rerendering
Tracker.afterFlush(() => {
// Wait for components initialization
Meteor.defer(() => $(inputSelector).click());
});
}
} }
}); });

View File

@ -1,4 +1,5 @@
import './base'; import './base';
import './bootstrap'; import './bootstrap';
import './playground/playground.html'; import './playground/playground.html';
import './playground/playground.styl';
import './playground/playground.js'; import './playground/playground.js';

View File

@ -1,14 +1,16 @@
<template name="componentPlayground"> <template name="componentPlayground">
<!-- <div class="component-playground">
<div class="p-a-3"> {{>measureFlow}}
{{>selectTree <!--
key='label' <div class="p-a-3">
items=instance.items {{>selectTree
label='Assign label' key='label'
searchPlaceholder='Search labels' items=instance.items
storageKey='measureLabelCommon' label='Assign label'
}} searchPlaceholder='Search labels'
storageKey='measureLabelCommon'
}}
</div>
-->
</div> </div>
-->
{{>measureFlow}}
</template> </template>

View File

@ -42,3 +42,8 @@ Template.componentPlayground.onCreated(() => {
value: 'Category 3' value: 'Category 3'
}]; }];
}); });
Template.componentPlayground.onRendered(() => {
const instance = Template.instance();
instance.$('.measure-flow').draggable();
});

View File

@ -0,0 +1,2 @@
.component-playground
height: 100vh

View File

@ -7,7 +7,7 @@ OHIF.string = {};
// Search for some string inside any object or array // Search for some string inside any object or array
OHIF.string.search = (object, query, property=null, result=[]) => { OHIF.string.search = (object, query, property=null, result=[]) => {
// Create the search pattern // Create the search pattern
const pattern = new RegExp($.trim(query).replace(/\s/gi, '|'), 'i'); const pattern = new RegExp($.trim(query), 'i');
_.each(object, item => { _.each(object, item => {
// Stop here if item is empty // Stop here if item is empty

View File

@ -1,3 +1,5 @@
import { OHIF } from 'meteor/ohif:core';
// Allow attaching to jQuery selectors // Allow attaching to jQuery selectors
$.fn.bounded = function(options) { $.fn.bounded = function(options) {
_.each(this, element => { _.each(this, element => {
@ -63,7 +65,7 @@ class Bounded {
this.$element.removeClass('bounded'); this.$element.removeClass('bounded');
} }
spatialInfo(element) { static spatialInfo(element) {
// Create the result object // Create the result object
const result = {}; const result = {};
@ -105,8 +107,8 @@ class Bounded {
defineEventHandlers() { defineEventHandlers() {
this.spatialChangedHandler = event => { this.spatialChangedHandler = event => {
// Get the spatial information for element and its bounding element // Get the spatial information for element and its bounding element
const elementInfo = this.spatialInfo(this.element); const elementInfo = Bounded.spatialInfo(this.element);
const boundingInfo = this.spatialInfo(this.boundingElement); const boundingInfo = Bounded.spatialInfo(this.boundingElement);
// Fix element's x positioning and width // Fix element's x positioning and width
if (this.allowResizing && elementInfo.width > boundingInfo.width) { if (this.allowResizing && elementInfo.width > boundingInfo.width) {
@ -143,3 +145,5 @@ class Bounded {
} }
} }
OHIF.Bounded = Bounded;

View File

@ -2,4 +2,6 @@
#imageViewerViewports #imageViewerViewports
height: 100% height: 100%
padding-bottom: 1px
padding-right: 6px
width: 100% width: 100%

View File

@ -1,7 +1,11 @@
<template name="imageControls"> <template name="imageControls">
<div class="imageControls"> <div class="imageControls">
<div id="scrollbar"> <div id="scrollbar">
<input id="imageSlider" type="range" min="1" val="1"/> <input id="imageSlider"
type="range"
min="1"
value="{{imageIndex}}"
max="{{numImages}}"/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,6 +1,20 @@
const slideTimeoutTime = 40; const slideTimeoutTime = 40;
let slideTimeout; let slideTimeout;
Template.imageControls.onRendered(() => {
const instance = Template.instance();
Meteor.defer(() => {
// Set the current imageSlider width to its parent's height
// (because webkit is stupid and can't style vertical sliders)
const $slider = instance.$('#imageSlider');
const $element = $slider.parents().eq(2).siblings('.imageViewerViewport');
const viewportHeight = $element.height();
$slider.width(viewportHeight - 20);
});
})
Template.imageControls.events({ Template.imageControls.events({
'input #imageSlider, change #imageSlider': function(e) { 'input #imageSlider, change #imageSlider': function(e) {
// Note that we throttle requests to prevent the // Note that we throttle requests to prevent the

View File

@ -17,6 +17,7 @@ $imageSliderCursor = grab
padding: 5px padding: 5px
#scrollbar #scrollbar
height: calc(100% - 20px);
margin-top: 5px margin-top: 5px
width: 31px width: 31px

View File

@ -86,32 +86,6 @@ function loadDisplaySetIntoViewport(data, templateData) {
imageIds: imageIds imageIds: imageIds
}; };
// Show or hide the image scrollbar depending
// on the number of images in the stack
var currentOverlay = $(element).siblings('.imageViewerViewportOverlay');
var imageControls = currentOverlay.find('.imageControls');
currentOverlay.find('.imageControls').height($(element).height());
if (stack.imageIds.length === 1) {
imageControls.hide();
currentOverlay.find('.topright, .bottomright').css('right', '3px');
} else {
imageControls.show();
currentOverlay.find('.topright, .bottomright').css('right', '39px');
// Update the maximum value of the slider
var currentImageSlider = currentOverlay.find('#imageSlider');
currentImageSlider.attr('max', stack.imageIds.length);
currentImageSlider.val(1);
// Set it's width to its parent's height
// (because webkit is stupid and can't style vertical sliders)
var scrollbar = currentOverlay.find('#scrollbar');
scrollbar.height(scrollbar.parent().height() - 20);
var overlayHeight = currentImageSlider.parent().height();
currentImageSlider.width(overlayHeight);
}
// Get the current image ID for the stack that will be rendered // Get the current image ID for the stack that will be rendered
imageId = imageIds[stack.currentImageIdIndex]; imageId = imageIds[stack.currentImageIdIndex];
@ -296,11 +270,6 @@ function loadDisplaySetIntoViewport(data, templateData) {
var stack = toolData.data[0]; var stack = toolData.data[0];
// Update the imageSlider value
var currentOverlay = $(element).siblings('.imageViewerViewportOverlay');
var currentImageSlider = currentOverlay.find('#imageSlider');
currentImageSlider.val(stack.currentImageIdIndex + 1);
// If this viewport is displaying a stack of images, save the current image // If this viewport is displaying a stack of images, save the current image
// index in the stack to the global ViewerData object, as well as the Meteor Session. // index in the stack to the global ViewerData object, as well as the Meteor Session.
var stack = cornerstoneTools.getToolState(element, 'stack'); var stack = cornerstoneTools.getToolState(element, 'stack');
@ -319,27 +288,6 @@ function loadDisplaySetIntoViewport(data, templateData) {
// Set a random value for the Session variable in order to trigger an overlay update // Set a random value for the Session variable in order to trigger an overlay update
Session.set('CornerstoneNewImage' + viewportIndex, Random.id()); Session.set('CornerstoneNewImage' + viewportIndex, Random.id());
function OnStackScroll(e, eventData) {
// Get the element and stack data
var element = e.target;
var toolData = cornerstoneTools.getToolState(element, 'stack');
if (!toolData || !toolData.data || !toolData.data.length) {
return;
}
var stack = toolData.data[0];
// Update the imageSlider value
var currentOverlay = $(element).siblings('.imageViewerViewportOverlay');
var currentImageSlider = currentOverlay.find('#imageSlider');
currentImageSlider.val(stack.currentImageIdIndex + 1);
}
$(element).off('CornerstoneStackScroll', OnStackScroll);
if (stack.imageIds.length > 1) {
$(element).on('CornerstoneStackScroll', OnStackScroll);
}
// Define a function to trigger an event whenever a new viewport is being used // Define a function to trigger an event whenever a new viewport is being used
// This is used to update the value of the "active viewport", when the user interacts // This is used to update the value of the "active viewport", when the user interacts
// with a new viewport element // with a new viewport element

View File

@ -1,5 +1,5 @@
<template name="viewportOverlay"> <template name="viewportOverlay">
<div class="imageViewerViewportOverlay noselect"> <div class="imageViewerViewportOverlay noselect {{#if gt numImages 1}}controlsVisible{{/if}}">
{{ #unless tagDisplaySpecified }} {{ #unless tagDisplaySpecified }}
<div class="topleft dicomTag"> <div class="topleft dicomTag">
<div>{{formatPN patientName}}</div> <div>{{formatPN patientName}}</div>
@ -11,15 +11,19 @@
<div>{{formatDA studyDate}} {{formatTM studyTime}}</div> <div>{{formatDA studyDate}} {{formatTM studyTime}}</div>
</div> </div>
<div class="bottomright dicomTag"> <div class="bottomright dicomTag">
<div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 2}}%{{/if}}</div> <div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 0}}%{{/if}}</div>
<div>{{compression}}</div> <div>{{compression}}</div>
<div>{{wwwc}}</div> <div>{{wwwc}}</div>
</div> </div>
<div class="bottomleft dicomTag"> <div class="bottomleft dicomTag">
<div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div> <div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div>
<div>{{#if numImages}}Img: {{imageNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div> <div>{{#if gt numImages 1}}Img: {{instanceNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div>
<div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div> <div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div>
<div>{{imageDimensions}}</div> <div>{{imageDimensions}}</div>
<div>{{#if location}}Loc: {{location}} mm{{/if}}
{{#if thickness}}Thick: {{thickness}} mm{{/if}}
{{#if spacingBetweenSlices}}Spacing: {{spacingBetweenSlices}} mm{{/if}}
</div>
<div>{{seriesDescription}}</div> <div>{{seriesDescription}}</div>
</div> </div>
{{ /unless }} {{ /unless }}
@ -34,11 +38,11 @@
</div> </div>
<div class="bottomright dicomTag"> <div class="bottomright dicomTag">
<div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div> <div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div>
<div>{{#if numImages}}Img: {{imageNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div> <div>{{#if gt numImages 1}}Img: {{instanceNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div>
<div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div> <div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div>
<div>{{imageDimensions}}</div> <div>{{imageDimensions}}</div>
<div>{{seriesDescription}}</div> <div>{{seriesDescription}}</div>
<div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 2}}%{{/if}}</div> <div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 0}}%{{/if}}</div>
<div>{{compression}}</div> <div>{{compression}}</div>
<div>{{wwwc}}</div> <div>{{wwwc}}</div>
</div> </div>
@ -54,15 +58,18 @@
</div> </div>
<div class="bottomleft dicomTag"> <div class="bottomleft dicomTag">
<div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div> <div>{{#if seriesNumber}}Ser: {{seriesNumber}}{{/if}}</div>
<div>{{#if numImages}}Img: {{imageNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div> <div>{{#if gt numImages 1}}Img: {{instanceNumber}} ({{imageIndex}}/{{numImages}}){{/if}}</div>
<div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div> <div>{{#if frameRate}}{{frameRate}} FPS{{/if}}</div>
<div>{{imageDimensions}}</div> <div>{{imageDimensions}}</div>
<div>{{seriesDescription}}</div> <div>{{seriesDescription}}</div>
<div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 2}}%{{/if}}</div> <div>{{#if zoom}}Zoom: {{formatNumberPrecision zoom 0}}%{{/if}}</div>
<div>{{compression}}</div> <div>{{compression}}</div>
<div>{{wwwc}}</div> <div>{{wwwc}}</div>
</div> </div>
{{ /if }} {{ /if }}
{{>imageControls}}
{{#if gt numImages 1}}
{{> imageControls imageIndex=imageIndex numImages=numImages}}
{{ /if }}
</div> </div>
</template> </template>

View File

@ -180,6 +180,12 @@ Template.viewportOverlay.helpers({
patientId: function() { patientId: function() {
return getPatient.call(this, 'id'); return getPatient.call(this, 'id');
}, },
patientBirthDate: function() {
return getPatient.call(this, 'birthDate');
},
patientSex: function() {
return getPatient.call(this, 'sex');
},
studyDate: function() { studyDate: function() {
return getStudy.call(this, 'studyDate'); return getStudy.call(this, 'studyDate');
}, },
@ -204,9 +210,67 @@ Template.viewportOverlay.helpers({
seriesNumber: function() { seriesNumber: function() {
return getSeries.call(this, 'seriesNumber'); return getSeries.call(this, 'seriesNumber');
}, },
imageNumber: function() { instanceNumber: function() {
return getInstance.call(this, 'instanceNumber'); return getInstance.call(this, 'instanceNumber');
}, },
thickness() {
// Displays Slice Thickness (0018,0050)
return getInstance.call(this, 'sliceThickness');
},
location() {
// Displays Slice Location (0020,1041), if present.
// - Otherwise, displays Table Position (0018,9327)
// - TODO: Otherwise, displays a value derived from Image Position (Patient) (0020,0032)
const sliceLocation = getInstance.call(this, 'sliceLocation');
if (sliceLocation !== '') {
return sliceLocation;
}
const tablePosition = getInstance.call(this, 'tablePosition');
if (tablePosition !== '') {
return tablePosition;
}
return getInstance.call(this, 'imagePositionPatient');
},
spacingBetweenSlices() {
// Displays Spacing Between Slices (0018,0088), if present.
// TODO: Otherwise, displays a value derived from successive values
// of Image Position (Patient) (0020,0032) perpendicular to
// the Image Orientation (Patient) (0020,0037)
return getInstance.call(this, 'spacingBetweenSlices');
},
compression() {
// Displays whether or not lossy compression has been applied:
//
// - Checks Lossy Image Compression (0028,2110)
// - If so, displays the value of Lossy Image Compression Ratio (0028,2112)
// and Lossy Image Compression Method (0028,2114)
Session.get('CornerstoneNewImage' + this.viewportIndex);
if (!this.imageId) {
return false;
}
var instance = cornerstoneTools.metaData.get('instance', this.imageId);
if (!instance) {
return '';
}
if (instance.lossyImageCompression === '01' &&
instance.lossyImageCompressionRatio !== '') {
const compressionMethod = instance.lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = parseFloat(instance.lossyImageCompressionRatio).toFixed(2);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
},
tagDisplayLeftOnly: function() { tagDisplayLeftOnly: function() {
return getTagDisplay.call(this, 'side') === 'L'; return getTagDisplay.call(this, 'side') === 'L';
}, },
@ -216,12 +280,6 @@ Template.viewportOverlay.helpers({
tagDisplaySpecified: function() { tagDisplaySpecified: function() {
return getTagDisplay.call(this, 'side'); return getTagDisplay.call(this, 'side');
}, },
imageIndex: function() {
return getInstance.call(this, 'index');
},
numImages: function() {
return getSeries.call(this, 'numImages');
},
imageIndex: function() { imageIndex: function() {
Session.get('CornerstoneNewImage' + this.viewportIndex); Session.get('CornerstoneNewImage' + this.viewportIndex);
var stack = getStackDataIfNotEmpty(this.viewportIndex); var stack = getStackDataIfNotEmpty(this.viewportIndex);

View File

@ -31,3 +31,7 @@ $viewportTagPadding = 20px
.priorIndicator .priorIndicator
font-weight: bold font-weight: bold
color: yellow color: yellow
&.controlsVisible
.topright, .bottomright
right: "calc(%s + 19px)" % $viewportTagPadding

View File

@ -26,7 +26,8 @@ addMetaData = function(imageId, data) {
studyInstanceUid: studyMetaData.studyInstanceUid, studyInstanceUid: studyMetaData.studyInstanceUid,
studyDate: studyMetaData.studyDate, studyDate: studyMetaData.studyDate,
studyTime: studyMetaData.studyTime, studyTime: studyMetaData.studyTime,
studyDescription: studyMetaData.studyDescription studyDescription: studyMetaData.studyDescription,
institutionName: studyMetaData.institutionName
}; };
metaData.series = { metaData.series = {
@ -37,23 +38,13 @@ addMetaData = function(imageId, data) {
numImages: numImages numImages: numImages
}; };
metaData.instance = { metaData.instance = instanceMetaData;
wadouri: instanceMetaData.wadouri,
imageType: instanceMetaData.imageType,
photometricInterpretation: instanceMetaData.photometricInterpretation,
sopInstanceUid: instanceMetaData.sopInstanceUid,
sopClassUid: instanceMetaData.sopClassUid,
instanceNumber: instanceMetaData.instanceNumber,
laterality: instanceMetaData.laterality,
viewPosition: instanceMetaData.viewPosition,
sliceThickness: instanceMetaData.sliceThickness,
frameTime: instanceMetaData.frameTime,
index: imageIndex
};
metaData.patient = { metaData.patient = {
name: studyMetaData.patientName, name: studyMetaData.patientName,
id: studyMetaData.patientId id: studyMetaData.patientId,
birthDate: studyMetaData.patientBirthDate,
sex: studyMetaData.patientSex
}; };
// If there is sufficient information, populate // If there is sufficient information, populate

View File

@ -37,13 +37,19 @@ function configureTools() {
cornerstoneTools.toolColors.setActiveColor('#00ffff'); //rgb(0, 255, 0)' cornerstoneTools.toolColors.setActiveColor('#00ffff'); //rgb(0, 255, 0)'
// Set the configuration values for the text annotation (Arrow) tool // Set the configuration values for the text annotation (Arrow) tool
var annotateConfig = { const annotateConfig = {
getTextCallback: getAnnotationTextCallback, getTextCallback: getAnnotationTextCallback,
changeTextCallback: changeAnnotationTextCallback, changeTextCallback: changeAnnotationTextCallback,
drawHandles: false, drawHandles: false,
arrowFirst: true arrowFirst: true
}; };
cornerstoneTools.arrowAnnotate.setConfiguration(annotateConfig); cornerstoneTools.arrowAnnotate.setConfiguration(annotateConfig);
const zoomConfig = {
minScale: 0.05,
maxScale: 10
};
cornerstoneTools.zoom.setConfiguration(zoomConfig);
} }
toolManager = { toolManager = {

View File

@ -43,12 +43,15 @@ function resultDataToStudyMetadata(studyInstanceUid, resultData) {
seriesList: seriesList, seriesList: seriesList,
patientName: anInstance[0x00100010], patientName: anInstance[0x00100010],
patientId: anInstance[0x00100020], patientId: anInstance[0x00100020],
patientBirthDate: anInstance[0x00100030],
patientSex: anInstance[0x00100040],
accessionNumber: anInstance[0x00080050], accessionNumber: anInstance[0x00080050],
studyDate: anInstance[0x00080020], studyDate: anInstance[0x00080020],
modalities: anInstance[0x00080061], modalities: anInstance[0x00080061],
studyDescription: anInstance[0x00081030], studyDescription: anInstance[0x00081030],
imageCount: anInstance[0x00201208], imageCount: anInstance[0x00201208],
studyInstanceUid: anInstance[0x0020000D] studyInstanceUid: anInstance[0x0020000D],
institutionName: anInstance[0x00080080]
}; };
resultData.forEach(function(instanceRaw) { resultData.forEach(function(instanceRaw) {
@ -78,7 +81,9 @@ function resultDataToStudyMetadata(studyInstanceUid, resultData) {
imagePositionPatient: instance[0x00200032], imagePositionPatient: instance[0x00200032],
imageOrientationPatient: instance[0x00200037], imageOrientationPatient: instance[0x00200037],
frameOfReferenceUID: instance[0x00200052], frameOfReferenceUID: instance[0x00200052],
sliceThickness: parseFloat(instance[0x00180050]),
sliceLocation: parseFloat(instance[0x00201041]), sliceLocation: parseFloat(instance[0x00201041]),
tablePosition: parseFloat(instance[0x00189327]),
samplesPerPixel: parseFloat(instance[0x00280002]), samplesPerPixel: parseFloat(instance[0x00280002]),
photometricInterpretation: instance[0x00280004], photometricInterpretation: instance[0x00280004],
rows: parseFloat(instance[0x00280010]), rows: parseFloat(instance[0x00280010]),
@ -96,7 +101,12 @@ function resultDataToStudyMetadata(studyInstanceUid, resultData) {
laterality: instance[0x00200062], laterality: instance[0x00200062],
viewPosition: instance[0x00185101], viewPosition: instance[0x00185101],
numFrames: parseFloat(instance[0x00280008]), numFrames: parseFloat(instance[0x00280008]),
frameTime: parseFloat(instance[0x00181063]) frameTime: parseFloat(instance[0x00181063]),
lossyImageCompression: instance[0x00282110],
derivationDescription: instance[0x00282111],
lossyImageCompressionRatio: instance[0x00282112],
lossyImageCompressionMethod: instance[0x00282114],
spacingBetweenSlices: instance[0x00180088]
}; };
// Retrieve the actual data over WADO-URI // Retrieve the actual data over WADO-URI

View File

@ -59,7 +59,8 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
modalities: DICOMWeb.getString(anInstance['00080061']), modalities: DICOMWeb.getString(anInstance['00080061']),
studyDescription: DICOMWeb.getString(anInstance['00081030']), studyDescription: DICOMWeb.getString(anInstance['00081030']),
imageCount: DICOMWeb.getString(anInstance['00201208']), imageCount: DICOMWeb.getString(anInstance['00201208']),
studyInstanceUid: DICOMWeb.getString(anInstance['0020000D']) studyInstanceUid: DICOMWeb.getString(anInstance['0020000D']),
institutionName: DICOMWeb.getString(anInstance['00080080'])
}; };
resultData.forEach(function(instance) { resultData.forEach(function(instance) {
@ -107,7 +108,11 @@ function resultDataToStudyMetadata(server, studyInstanceUid, resultData) {
viewPosition: DICOMWeb.getString(instance['00185101']), viewPosition: DICOMWeb.getString(instance['00185101']),
numFrames: DICOMWeb.getNumber(instance['00280008']), numFrames: DICOMWeb.getNumber(instance['00280008']),
frameTime: DICOMWeb.getNumber(instance['00181063']), frameTime: DICOMWeb.getNumber(instance['00181063']),
sliceThickness: DICOMWeb.getNumber(instance['00180050']) sliceThickness: DICOMWeb.getNumber(instance['00180050']),
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
derivationDescription: DICOMWeb.getString(instance['00282111']),
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
}; };
if (server.imageRendering === 'wadouri') { if (server.imageRendering === 'wadouri') {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

After

Width:  |  Height:  |  Size: 277 KiB