qiprofile
Version:
Quantitative Imaging Profile ( QiPr) web application
777 lines (742 loc) • 31.8 kB
text/coffeescript
define ['angular', 'dc', 'moment', 'roman', 'lodash', 'crossfilter', 'd3',
'breast', 'sarcoma', 'subject', 'session', 'tnm', 'helpers'],
(ng, dc, moment, roman) ->
collection = ng.module(
'qiprofile.collection',
['qiprofile.breast', 'qiprofile.sarcoma', 'qiprofile.subject',
'qiprofile.session', 'qiprofile.tnm', 'qiprofile.helpers']
)
collection.factory 'Collection', ['Breast', 'Sarcoma', 'Subject', 'Session', 'TNM', 'DateHelper',
(Breast, Sarcoma, Subject, Session, TNM, DateHelper) ->
# The data series configuration. These are all of the data series that
# the dimensional charting (DC) charts support and are in the order in
# which they appear in the X and Y axis selection dropdowns. Each has
# the following properties:
#
# * label - Appears in the dropdown picklists and as chart axis labels.
# * collection - The collection(s) for which the data series is valid.
# May be 'all' or a list of specific collections.
# * accessor - The data accessor.
#
# Note that necrosis percent can exist either as a single value or a
# range. In the latter case, the mean of the upper and lower values is
# obtained and plotted on the charts.
#
# TODO - push the common config data into the respective services.
# See the k-trans.coffee TODO item. See also the CATEGORICAL_VALUES
# TODO below.
#
# TODO - Add any other data series that we want to support
# (e.g. demographics) to the chart configuration.
#
DATA_SERIES_CONFIG =
'fxlKTrans':
label: 'FXL Ktrans'
collection: [
'all'
]
accessor: (modelingResult) -> modelingResult.fxlKTrans.average
'fxrKTrans':
label: 'FXR Ktrans'
collection: [
'all'
]
accessor: (modelingResult) -> modelingResult.fxrKTrans.average
'deltaKTrans':
label: 'delta Ktrans'
collection: [
'all'
]
accessor: (modelingResult) -> modelingResult.deltaKTrans.average
'vE':
label: 'v_e'
collection: [
'all'
]
accessor: (modelingResult) -> modelingResult.vE.average
'tauI':
label: 'tau_i'
collection: [
'all'
]
accessor: (modelingResult) -> modelingResult.tauI.average
'tumorLength':
label: 'Tumor Length (mm)'
collection: [
'all'
]
accessor: (tumor) ->
return null unless tumor.extent?
if tumor.extent.length? then tumor.extent.length else null
'tumorWidth':
label: 'Tumor Width (mm)'
collection: [
'all'
]
accessor: (tumor) ->
return null unless tumor.extent?
if tumor.extent.width? then tumor.extent.width else null
'tumorDepth':
label: 'Tumor Depth (mm)'
collection: [
'all'
]
accessor: (tumor) ->
return null unless tumor.extent?
if tumor.extent.depth? then tumor.extent.depth else null
'breastTNMStage':
label: 'TNM Stage'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.tnm?
stage = Breast.stage tumor.tnm
stage.replace /^\d+/, roman.romanize
'rcbIndex':
label: 'RCB Index'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.rcb?
rcb = Breast.residualCancerBurden tumor
rcb.index
'recurrenceScore':
label: 'Recurrence Score'
collection: [
'Breast'
]
scale: null
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
Breast.recurrenceScore tumor.geneticExpression.normalizedAssay
'ki67Expression':
label: 'Ki67 Expression'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression?
if tumor.geneticExpression.ki67?
tumor.geneticExpression.ki67
else
null
'gstm1':
label: 'GSTM1 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.gstm1?
tumor.geneticExpression.normalizedAssay.gstm1
else
null
'cd68':
label: 'CD68 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.cd68?
tumor.geneticExpression.normalizedAssay.cd68
else
null
'bag1':
label: 'BAG1 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.bag1?
tumor.geneticExpression.normalizedAssay.bag1
else
null
'grb7':
label: 'GRB7 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.her2?
tumor.geneticExpression.normalizedAssay.her2.grb7
else
null
'her2':
label: 'HER2 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.her2?
tumor.geneticExpression.normalizedAssay.her2.her2
else
null
'er':
label: 'ER Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.estrogen?
tumor.geneticExpression.normalizedAssay.estrogen.er
else
null
'pgr':
label: 'PGR Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.estrogen?
tumor.geneticExpression.normalizedAssay.estrogen.pgr
else
null
'bcl2':
label: 'BCL2 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.estrogen?
tumor.geneticExpression.normalizedAssay.estrogen.bcl2
else
null
'scube2':
label: 'SCUBE2 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.estrogen?
tumor.geneticExpression.normalizedAssay.estrogen.scube2
else
null
'ki67':
label: 'Ki67 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.proliferation?
tumor.geneticExpression.normalizedAssay.proliferation.ki67
else
null
'stk15':
label: 'STK15 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.proliferation?
tumor.geneticExpression.normalizedAssay.proliferation.stk15
else
null
'survivin':
label: 'Survivin Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.proliferation?
tumor.geneticExpression.normalizedAssay.proliferation.survivin
else
null
'ccnb1':
label: 'CCNB1 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.proliferation?
tumor.geneticExpression.normalizedAssay.proliferation.ccnb1
else
null
'mybl2':
label: 'MYBL2 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.proliferation?
tumor.geneticExpression.normalizedAssay.proliferation.mybl2
else
null
'mmp11':
label: 'MMP11 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.invasion?
tumor.geneticExpression.normalizedAssay.invasion.mmp11
else
null
'ctsl2':
label: 'CTSL2 Normalized Assay'
collection: [
'Breast'
]
accessor: (tumor) ->
return null unless tumor.geneticExpression.normalizedAssay?
if tumor.geneticExpression.normalizedAssay.invasion?
tumor.geneticExpression.normalizedAssay.invasion.ctsl2
else
null
'sarcomaTNMStage':
label: 'TNM Stage'
collection: [
'Sarcoma'
]
accessor: (tumor) ->
return null unless tumor.tnm?
stage = Sarcoma.stage tumor.tnm, TNM.summaryGrade(tumor.tnm)
stage.replace /^\d+/, roman.romanize
'necrosisPercent':
label: 'Necrosis Percent'
collection: [
'Sarcoma'
]
accessor: (tumor) ->
return null unless tumor.necrosisPercent?
if tumor.necrosisPercent._cls == 'NecrosisPercentValue'
tumor.necrosisPercent.value
else if tumor.necrosisPercent._cls == 'NecrosisPercentRange'
_.mean [
tumor.necrosisPercent.start.value
tumor.necrosisPercent.stop.value
]
else
null
# Map the data series key values to the display labels.
LABELS = _.mapValues(DATA_SERIES_CONFIG, (obj) ->
obj.label
)
# The complete list of data series.
DATA_SERIES = _.keys DATA_SERIES_CONFIG
# The imaging data series.
IMAGING_DATA_SERIES = DATA_SERIES.slice(0, 5)
# The categorical type data series {data series: value extent array}
# associative look-up object.
#
# TODO - Get the stages on demand given the current collection rather
# than list the known collections here, e.g. make a service
# collection.coffee with:
# factory 'Collection', ... ->
# SERVICES = {breast: Breast, ...}
# service: (key) -> SERVICES[key] or throw error
# extent: (collection) -> service(key).stageExtent()
# then replace use of CATEGORICAL_VALUES by:
# Collection.extent(key)
#
# The application tends to break if detailed domain knowledge
# is redundantly dispersed throughout the source code. There
# are other opportunities to consolidate domain knowledge
# in services and delegate to those services in this file.
CATEGORICAL_VALUES =
'breastTNMStage': Breast.stageExtent().map(
(s) -> s.replace /^\d+/, roman.romanize
)
'sarcomaTNMStage': Sarcoma.stageExtent().map(
(s) -> s.replace /^\d+/, roman.romanize
)
# The chart layout parameters.
CHART_LAYOUT_PARAMS =
subjectChartHeight: 150
collectionChartHeight: 250
symbolSize: 8
subjectChartSymbol: 'diamond'
subjectChartPadding: .5
collectionChartPadding: .2
ticks: 4
leftMargin: 6
# The default chart axes to be displayed, by collection. The X axes can
# include any data series that is valid for the collection. The Y axes
# can include only data series that have continuous data. Data series
# that have categorical/ordinal data types are excluded from the Y axis
# choices because the DC charting does not currently support such a
# configuration.
DEFAULT_AXES:
'Breast':
[
{
x: 'rcbIndex'
y: 'deltaKTrans'
}
{
x: 'breastTNMStage'
y: 'deltaKTrans'
}
{
x: 'recurrenceScore'
y: 'deltaKTrans'
}
{
x: 'deltaKTrans'
y: 'ki67Expression'
}
]
'Sarcoma':
[
{
x: 'necrosisPercent'
y: 'deltaKTrans'
}
{
x: 'sarcomaTNMStage'
y: 'deltaKTrans'
}
{
x: 'necrosisPercent'
y: 'vE'
}
{
x: 'deltaKTrans'
y: 'tumorLength'
}
]
# Creates an object containing only those data series that are valid
# for the current collection. These are the choices that will appear in
# the X and Y axis selection dropdowns. Categorical type data series
# are excluded from the Y axis choices.
#
# TODO - Suppress X/Y axis menu choices where no data exists for a
# particular data series throughout the collection.
#
# @param collection the target collection
# @returns the valid X and Y axis data series and labels for the target
# collection
dataSeriesChoices: (collection) ->
xChoices = new Object
yChoices = new Object
for ds in DATA_SERIES
config = DATA_SERIES_CONFIG[ds]
if 'all' in config.collection or collection in config.collection
xChoices[ds] = LABELS[ds]
if ds not in Object.keys(CATEGORICAL_VALUES)
yChoices[ds] = LABELS[ds]
choices =
x: xChoices
y: yChoices
# Obtains and formats the scatterplot data for display in the charts.
# The data consist of an array of objects where each object contains
# the subject and session numbers and dates followed by the the data
# series that are valid for the current collection, e.g.:
#
# {
# 'subject': 1
# 'session': 1
# 'date': "01/06/2013"
# 'fxlKTrans': 0.16492194885121594
# ...
# 'recurrenceScore': 76
# 'rcbIndex': 2.9283422241238926
# }
#
# Each object needs to contain all of the same keys. If data
# is not available for a data series, it must be assigned a null value.
#
# TODO - The chart should collect whatever properties are available
# rather than be constrained by choices. See the DATA_SERIES_CONFIG
# TODO above.
#
# TODO - Are there count(session) x max(1, count(tumors)) objects for
# each subject?
#
# @param charting the REST query result
# @param dataSeries the valid data series for the current collection
# @returns the scatterplot data
chartData: (charting, dataSeries) ->
# @param modeling the modeling object
# @param tumor the tumor pathology
# @returns a complete scatterplot data object
createDCObject = (modeling, tumor) ->
# Create a new data object with core properties.
session = modeling.session
subject = session.subject
date = session.date
#The Subject Detail page hyperlink.
sbjRef = Subject.hyperlink(subject)
#The Session Detail page hyperlink.
sessRef = Session.hyperlink(session)
# Make the chart data object. The chart data object combines the
# subject, session and modeling objects.
dcObject =
subject: _.extend({href: sbjRef}, subject)
session: _.extend({href: sessRef}, session)
# Iterate over the valid data series and add data to the object.
for ds in dataSeries
config = DATA_SERIES_CONFIG[ds]
if ds in IMAGING_DATA_SERIES
dcObject[ds] = config.accessor(modeling.result)
else if tumor?
dcObject[ds] = config.accessor(tumor)
else
dcObject[ds] = null
# Return the data object.
dcObject
# Initialize the data object array.
data = new Array
# Iterate over the subjects.
for sbj in charting
# Obtain the tumor pathology data from the Surgery encounter.
# tumors will be null iff there is no pathology report.
# A path report is required for a biopsy, optional for a
# surgery. However, the biopsy tumors could be an empty
# array, althought that should be an error.
#
# For now, we allow for at most one path report and raise an error
# otherwise.
#
# TODO - Support both a biopsy and a surgery path report.
# Then rework the tumors loop below.
#
# TODO - Support no path reports.
#
tumors = null
for enc in sbj.clinicalEncounters
if enc.pathology.tumors?
# More than one path report is not yet supported.
if tumors?
console.warn("Only one pathology report per subject is" +
" supported: #{ sbj.collection } Subject" +
" #{ sbj.number }")
if enc.isSurgery()
tumors = enc.pathology.tumors
# Make a chart data object for each tumor of each session. The
# data object consists of the modeling result properties and,
# if available, the tumor pathology data.
for session in sbj.sessions
for mdl in session.modelings
if tumors
# TODO - how are tumors distinguished? Should we aggregate
# the size across tumors? What about other clinical values?
# Current handling of more than one tumor is probably a bug.
for tumor in tumors
dcObject = createDCObject(mdl, tumor)
else
dcObject = createDCObject(mdl, null)
data.push dcObject
# Return the scatterplot data.
data
# Calculates the chart padding value for each continuous type data
# series. In the charts, the padding must be expressed in the same unit
# domains as the data being charted.
#
# FIXME - this breaks if data is empty. The configuration should
# suppress charts where no data exists for a particular data
# series throughout the collection, per the comment in the
# controller.
#
# @param data the scatterplot data
# @param dataSeries the valid data series for the current collection
# @returns the chart padding for each data series
chartPadding: (data, dataSeries) ->
# @param key the data series
# @returns the chart padding for the data series
dataSeriesPadding = (key) ->
values = _.map(data, key)
max = _.max(values)
min = _.min(values)
diff = max - min
# The padding is determined as follows:
# * If the values are all the same, then calculate a padding amount
# of an appropriate resolution for that value. The initial result
# value reflects the number of digits or decimal places of the
# scatterplot values. Each chart tick mark above and below that
# value is then set to 10 to the power of the result reduced by
# one.
# * Otherwise, calculate a padding amount based the chart layout
# parameter setting, e.g. a setting of .2 will give the chart 20%
# padding.
if diff == 0
max = Math.abs max
result = Math.ceil(Math.log(max) / Math.log(10))
if Math.abs(result) is Infinity then result = 0
pad = CHART_LAYOUT_PARAMS.ticks / 2 * Math.pow(10, result - 1)
else
pad = diff * CHART_LAYOUT_PARAMS.collectionChartPadding
# @param obj the padding object
# @param key the data series
# @returns the padding object
addDataSeriesPadding = (obj, key) ->
obj[key] = dataSeriesPadding(key)
obj
# Return the chart padding object.
padding = dataSeries.reduce(addDataSeriesPadding, {})
# The dimensional charting (DC) rendering function.
#
# @param config the chart configuration
renderCharts: (config) ->
# TODO - refactor this function body into app. 3 smaller functions.
#
# TODO - brushing selection gestures can not be applied to
# categorical data (e.g. TNM stage). Is there a workaround?
#
# Enables a specified D3 element to be moved to the front layer of
# the visualization, which is necessary if that element is to be
# bound to mouseover events (e.g. tooltips).
d3.selection::moveToFront = ->
@each ->
@parentNode.appendChild this
# Convenience temp config variables.
data = config.data
padding = config.padding
axes = config.axes
# Construct the multi-dimensional crossfilter.
xFilter = crossfilter(data)
# The subject/session dimension.
dim = xFilter.dimension (obj) ->
[
obj.subject.number
obj.session.number
]
# The subject/session group.
group = dim.group()
# The subject/session table configuration.
table = dc.dataTable '#qi-subject-table'
table.dimension dim
.group (obj) ->
"<a href=\"#{ obj.subject.href }\">Patient #{ obj.subject.number }</a>"
.sortBy (obj) -> obj.session.href
.columns [
(obj) ->
"<span class='qi-subject-table-col'><a href=\"#{ obj.session.href }\">Visit #{ obj.session.number }</a></span>"
(obj) ->
"<span class='qi-subject-table-col'>#{ obj.session.date.format('MM/DD/YYYY') }</span>"
]
# The largest subject number is the number of X tick marks.
maxSbjNbr = _.chain(data).map('subject.number').max().value()
# The largest session number is the number of Y tick marks.
maxSessNbr = _.chain(data).map('session.number').max().value()
# The subject/session chart configuration.
chart = dc.scatterPlot '#qi-subject-chart'
chart.dimension dim
.group group
.height CHART_LAYOUT_PARAMS.subjectChartHeight
.renderVerticalGridLines true
.renderHorizontalGridLines true
.symbolSize CHART_LAYOUT_PARAMS.symbolSize
.highlightedSize CHART_LAYOUT_PARAMS.symbolSize + 2
.symbol CHART_LAYOUT_PARAMS.subjectChartSymbol
# Construct a new scale with a range of ten categorical colors.
.colors d3.scale.category10()
# The symbol color is based on the data object's key 1 value, which
# is the session number.
.colorAccessor (obj) -> obj.key[1]
.xAxisLabel "Patient"
.yAxisLabel "Visit"
.elasticX true
.elasticY true
.x d3.scale.linear()
.y d3.scale.linear()
.xAxisPadding CHART_LAYOUT_PARAMS.subjectChartPadding
.yAxisPadding CHART_LAYOUT_PARAMS.subjectChartPadding
# Set the axis tick counts.
chart.xAxis().ticks(maxSbjNbr)
chart.yAxis().ticks(maxSessNbr)
# Pad the left margins.
chart.margins().left += CHART_LAYOUT_PARAMS.leftMargin
# Apply a renderlet and associate it with tooltip callbacks.
chart.on 'renderlet', (chart) ->
chart.selectAll '.symbol'
.on 'mouseover', (obj) ->
# Supply the tooltips. The tooltip element is the selection
# returned by d3 after the chart rendering call.
xText = "<strong>Subject:</strong> #{ obj.key[0] }"
yText = "<strong>Visit:</strong> #{ obj.key[1] }"
html = "#{ xText }<br/>#{ yText }"
leftOffset = d3.event.pageX + 5 + 'px'
topOffset = d3.event.pageY - 35 + 'px'
tooltip.html(html).style('left', leftOffset).style('top', topOffset)
tooltip.transition().duration(20).style 'opacity', 1
.on 'mouseout', (obj) ->
tooltip.transition().duration(50).style('opacity', 0)
# The axes determine the number of charts to display.
chartCnt = axes.length
# The charts are numbered from 1 to n, where n is the number of
# axis pairs defined in the chart config axes variable.
chartNbrs = _.range(1, chartCnt + 1)
# Set up the scatterplots.
charts = (dc.scatterPlot('#qi-collection-chart-' + chartNbr) for chartNbr in chartNbrs)
# Iterate over the charts.
for chart, i in charts
# TODO - make this for body a function.
xAxis = axes[i].x
yAxis = axes[i].y
# Set up the dimension based on the X and Y axis user selections.
dim = xFilter.dimension (obj) ->
[
obj[xAxis]
obj[yAxis]
]
# The chart group.
group = dim.group()
# The chart configuration.
chart.dimension dim
.group group
.height CHART_LAYOUT_PARAMS.collectionChartHeight
.renderVerticalGridLines true
.renderHorizontalGridLines true
.symbolSize CHART_LAYOUT_PARAMS.symbolSize
.highlightedSize CHART_LAYOUT_PARAMS.symbolSize + 2
.xAxisLabel LABELS[xAxis]
.yAxisLabel LABELS[yAxis]
.elasticY true
.y d3.scale.linear()
.yAxisPadding padding[yAxis]
chart.xAxis().ticks CHART_LAYOUT_PARAMS.ticks
chart.yAxis().ticks CHART_LAYOUT_PARAMS.ticks
chart.margins().left += CHART_LAYOUT_PARAMS.leftMargin
# Special configuration for X-axis categorical data.
if xAxis in Object.keys(CATEGORICAL_VALUES)
chart.elasticX false
.x d3.scale.ordinal().domain(CATEGORICAL_VALUES[xAxis])
.xUnits dc.units.ordinal
._rangeBandPadding 1
else
chart.elasticX true
.x d3.scale.linear()
.xAxisPadding padding[xAxis]
chart.on 'renderlet', (chart) ->
chart.selectAll '.symbol'
# Supply the tooltips.
.on 'mouseover', (obj) ->
xText = "<strong>x:</strong> #{ obj.key[0] }"
yText = "<strong>y:</strong> #{ obj.key[1] }"
html = "#{ xText }<br/>#{ yText }"
leftOffset = d3.event.pageX + 5 + 'px'
topOffset = d3.event.pageY - 35 + 'px'
tooltip.html(html).style('left', leftOffset).style('top', topOffset)
tooltip.transition().duration(20).style 'opacity', 1
.on 'mouseout', (obj) ->
tooltip.transition().duration(50).style 'opacity', 0
# Hide plot points where either the X or Y value is null. The
# data object's key 0 value is the X axis data value and the
# key 1 value is the Y axis data value.
.style 'opacity', (obj) ->
if obj.key[0]? and obj.key[1]? then 1 else 0
# Render the charts.
dc.renderAll()
# Move the chart data points to the front layer of the visualization.
d3.selectAll('.chart-body').moveToFront()
# The collection chart tooltip container.
d3.select('body').append('div').attr('class', 'qi-collection-tooltip')
# The tooltip div.
tooltip = d3.select('.qi-collection-tooltip').append('div').attr('class', 'tooltip').style 'opacity', 0
]