UNPKG

qiprofile

Version:

Quantitative Imaging Profile ( QiPr) web application

777 lines (742 loc) 31.8 kB
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 ]