qiprofile
Version:
Quantitative Imaging Profile ( QiPr) web application
361 lines (339 loc) • 14.7 kB
text/coffeescript
define ['angular', 'lodash', 'moment', 'helpers', 'chart', 'session'], (ng, _, moment) ->
timeline = ng.module 'qiprofile.timeline', ['qiprofile.helpers', 'qiprofile.chart', 'qiprofile.session']
timeline.factory 'Timeline', ['ObjectHelper', 'Chart', 'Session', (ObjectHelper, Chart, Session) ->
# A helper function to calculate the effective treatment dates.
# This function returns a [start, end] array, where:
# * start is the moment start date integer
# * end is the moment end date integer
# The default end date is today.
#
# TODO - depict a treatment with no end date as an arrow.
#
# @param treatment the treatment object
# @returns the [start, end] array
treatmentSpan = (treatment) ->
start = treatment.startDate
end = treatment.endDate or moment()
[start, end]
# Helper function to calculate the earliest and latest session,
# encounter and treatment dates.
#
# @param subject the target subject
# @returns the [earliest, latest] X axis range
minMax = (subject) ->
values = []
for trt in subject.treatments
trtSpan = treatmentSpan(trt)
for value in trtSpan
values.push(value)
for enc in subject.encounters
values.push(enc.date)
# Return the min and max date.
[_.min(values), _.max(values)]
# @param subject the target subject
# @returns the date values to plot
xValues = (subject) ->
# The session dates are displayed as X axis tick marks.
sessionDates = (sess.date for sess in subject.sessions)
# The starting values are the sorted session dates.
values = _.sortBy(sessionDates, (date) -> date.valueOf())
# The [earliest, latest] session, treatment or encounter
# date.
[low, high] = minMax(subject)
# Add the other dates only if they do not coincide with
# the session dates.
if low.valueOf() != _.first(sessionDates).valueOf()
values.unshift(low)
if high.valueOf() != _.last(sessionDates).valueOf()
values.push(high)
# Return the date values.
values
# Decorates the timeline as follows:
# * Add the session hyperlinks, encounter dates and treatment
# start-end bars.
# * Rotate the date x-axis tick labels
#
# Note: The session hyperlinks are ui-sref attributes. However,
# D3 mutates the DOM after the Angular digest cycle. Consequently,
# the callback must call Angular $compile with the current scope
# on each new hyperlink.ui-sref directive.
#
# @param chart the timeline chart element
# @param subject the displayed subject
# @param scope the (isolated) chart scope containing the options
# and data
# @param $state the ui-router $state
decorate: (chart, subject, scope, $state) ->
# The min and max X values. The dates are sorted by the
# configure method.
values = scope.data[0].values
low = _.first(values)
high = _.last(values)
# The treatment bar height, in em units
TREATMENT_BAR_HEIGHT = 1
# A treatment is designated by the HTML nabla special character
# (the wedge-like math del operator).
TREATMENT_SYMBOL = '\u2207'
# The Session Detail state.
SESSION_DETAIL_STATE = 'quip.collection.subject.session'
# Adds the session hyperlinks above the timeline. The template
# sets the callback attribute to this function. The new anchor
# elements are positioned dy em units above the timeline,
# plus a small padding. This allows for displaying the treatment
# bars and encounter points between the timeline and the
# hyperlinks.
#
# @param sessions the subject sessions
# @param xAxis the chart SVG X axis D3 selection
# @param low the minimum date
# @param high the maximum date
# @param dy the optional y offset in em units
addSessionDetailLinks = (sessions, xAxis, low, high, dy=0) ->
# Makes a new ui-sref anchor element that hyperlinks to the given
# session detail page.
#
# @param session the hyperlink target session
# @param tick the X axis tick mark
createSessionDetailLink = (session, tick) ->
# Make a new SVG text element to hold the session number.
text = tick.append('text')
# Position the session number a bit to right and above the tick
# mark, scooched up by the dy amount.
text.attr('dx', '-.2em').attr('dy', "-#{ 0.5 + dy }em")
text.style('text-anchor: middle')
# The text content is the session number.
text.text(session.number)
# The Session Detail state parameters.
params =
project: subject.project
collection: subject.collection
subject: subject.number
session: session.number
# The hyperlink click event handler.
handler = -> $state.go(SESSION_DETAIL_STATE, params)
# Wrap the text element in a hyperlink.
Chart.d3Hyperlink(text, handler)
# The tick marks.
ticks = xAxis.selectAll('.tick')
# The session milliseconds.
sessionMillis = (session.date.valueOf() for session in sessions)
# @param date the date to check
# @returns whether the date is a session date
isSessionDate = (date) -> _.includes(sessionMillis, date.valueOf())
# Make the Session Detail page links.
#
# The odd D3 *each* operator calls this function with
# *this* bound to the D3 tick selection and arguments
# the element "data", as D3 chooses to call it, and
# the iteration number. The element data in this
# case is the date. We want the D3 tick selection,
# which is given by d3.select(this).
#
# Note that we can't simply iterate over the ticks in a
# for loop, since that is a D3 taboo which results in
# garbage.
sessionIndex = 0
ticks.each (date, i) ->
if isSessionDate(date)
tick = d3.select(this)
createSessionDetailLink(sessions[sessionIndex], tick)
sessionIndex++
# Inserts an SVG bar for each treatment above the timeline.
#
# @param xAxisNode the chart SVG x axis element
# @param options the chart options
addTreatmentBars = (xAxis, low, high) ->
# The .nv-background xAxis contained in the X axis parent
# spaces the X axis.
xAxisNode = xAxis.node()
parent = d3.select(xAxisNode.parentNode)
background = parent.select('.nv-background')
spacer = background.select('rect')
axisWidth = spacer.attr('width')
# The date offset-to-pixel scaling factor.
factor = axisWidth / (high - low)
for trt in subject.treatments
[start, end] = treatmentSpan(trt)
left = (start - low) * factor
width = (end - start) * factor
# Allow for a 6-pixel minimum width.
if not width
left = left - 3
width = 6
# Place the new bar rectangle element in the DOM
# before the x axis element.
bar = parent.insert('svg:rect', -> xAxisNode)
# Set the bar dimensions.
bar.attr('height', "#{ TREATMENT_BAR_HEIGHT }em")
bar.attr('width', width)
# Set the bar style.
bar.classed("qi-timeline-#{ trt.treatmentType.toLowerCase() }", true)
# Position the bar.
bar.attr('x', left)
bar.attr('y', "-#{ TREATMENT_BAR_HEIGHT }em")
# Inserts a marker for each clinical encounter above the
# timeline.
#
# @param element the chart X axis D3 selection
# @param low the minimum date
# @param high the maximum date
addClinicalEncounters = (xAxis, low, high) ->
# The .nv-background xAxis contained in the X axis parent
# spaces the X axis.
xAxisNode = xAxis.node()
parent = d3.select(xAxisNode.parentNode)
background = parent.select('.nv-background')
spacer = background.select('rect')
axisWidth = spacer.attr('width')
# The X axis line element is contained in the axis parent.
line = parent.select('.nv-series-0')
# Unwrap the DOM element.
lineNode = line.node()
# The date offset-to-pixel scaling factor.
factor = axisWidth / (high - low)
# Insert the encounters.
for enc in subject.clinicalEncounters
date = enc.date
# The pixel offset.
offset = (date - low) * factor
# Place the new text element in the DOM before the
# x axis element. Scooch it over 6 pixels to center
# the marker.
text = parent.insert('svg:text')
text.attr('x', Math.floor(offset) - 6)
# Set the text style, e.g. .qi-timeline-surgery.
text.classed("qi-timeline-#{ enc.title.toLowerCase() }", true)
# Set the text content to a marker, specifically the HTML
# nabla math special character (the wedge-like del operator).
text.text(TREATMENT_SYMBOL)
# Adds the treatment and encounter legend directly before the
# SVG element.
#
# @param svg the SVG D3 selection
addLegend = (svg) ->
addTreatmentLegend = (parent) ->
# If there are no treatments, then bail out.
trts = subject.treatments
return if not trts.length
# Place the legend line.
p = parent.insert('p', -> svg.node())
p.text('Treatments: ')
p.classed({'col-md-offset-5': true, 'font-size: small': true})
# Sort by start date.
sorted = _.sortBy(trts, (trt) -> trt.start_date.valueOf())
# The treatment labels.
labels = _.uniq(trt.treatment_type for trt in sorted)
# The treatment label size.
label_lengths = (label.length for label in labels)
len = _.max(label_lengths) + 2
for label in labels
# Place the new span element.
span = p.append('span')
# The legend style.
span.classed("qi-timeline-#{ label.toLowerCase() }", true)
# The span content is the treatment type label.
span.text(label)
addEncounterLegend = (parent) ->
# Bail if no encounters are displayed.
encs = subject.clinicalEncounters
return if not encs.length
# Place the legend line.
p = parent.insert('p', -> svg.node())
p.text('Encounters: ')
p.classed({'col-md-offset-5': true, 'font-size: small': true})
# Sort by start date.
sorted = _.sortBy(encs, (enc) -> enc.date.valueOf())
# The encounter labels.
labels = _.uniq(enc.title for enc in sorted)
for label in labels
# Place the new span element.
span = p.append('span')
# The legend style.
span.classed("qi-timeline-#{ label.toLowerCase() }", true)
# The span content is the symbol which designates the encounter
# followed by the encounter type.
span.text(TREATMENT_SYMBOL + label)
# The legend is inserted directly before the svg element.
svgNode = svg.node()
parent = d3.select(svgNode.parentNode)
addTreatmentLegend(parent)
addEncounterLegend(parent)
# Find the SVG element.
svg = d3.select('svg')
if not svg?
throw new ReferenceError("The svg element was not found.")
# The X axis element.
xAxis = svg.select('.nv-x')
# The session hyperlink vertical offset.
if _.some(subject.treatments) or _.some(subject.clinicalEncounters)
dy = TREATMENT_BAR_HEIGHT
else
dy = 0
# Scooch the X axis label up to work around the
# nvd3 or d3 bug described in the config rotateLabels
# setting below.
workAroundAxisLabelBug = (xAxis) ->
label = xAxis.select('.nv-axislabel')
y = label.attr('y')
label.attr('y', y - 10)
# The earliest and latest dates.
[low, high] = minMax(subject)
# Decorate the chart.
addTreatmentBars(xAxis, low, high)
addClinicalEncounters(xAxis, low, high)
addSessionDetailLinks(subject.sessions, xAxis, low, high, dy)
addLegend(svg)
workAroundAxisLabelBug(xAxis)
# @param subject the subject to display
# @returns the nvd3 chart configuration
configure: (subject) ->
# The values to plot.
values = xValues(subject)
# Return the {options, data} configuration.
options:
chart:
type: 'lineChart'
height: 83
# We roll our own legend in the callback.
showLegend: false
# Note: the trivial X value accessor works around the
# following nvd3 bug:
# * If there is no x chart accessor option, then the chart
# render results in an obscure error in the d3 voronoi
# tesselation:
# Cannot read property 'x' of null
# This bug possibly results from a nvd3 expectation that
# if there is a y option then there is also an x option.
# The work-around is to supply a trivial x option.
# TODO - isolate this bug with a nvd3 test case and report
# it to nvd3.
x: (date) -> date
xAxis:
axisLabel: 'Visit Date'
showMaxMin: false
tickValues: values
tickFormat: Chart.formatDate
# Note - due to a nvd3 or d3 bug, rotating the labels
# forces the axis out of the chart display. Adding a
# margin has no effect. The only recourse is to hammer
# the DOM afterwards to move the label up.
rotateLabels: -30
# Note: a nvd3 bug clobbers the custom tickSize set below.
# The work-around is to reduce the height to a just-so
# value that loses the tick marks (yuck). Setting the
# xAxis height would be an alternative, but this has
# no effect, probably due to another nvd3 bug.
# TODO - file a nvd3 bug and revisit this in 2017.
#tickSize: 4
tooltip:
enabled: false
xScale: d3.time.scale()
# The session dates align on the X axis,
# hence the Y value is always zero.
y: -> 0
showYAxis: false
data: [
key: 'Visit Date'
values: values
]
]