UNPKG

qiprofile

Version:

Quantitative Imaging Profile ( QiPr) web application

361 lines (339 loc) 14.7 kB
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 ] ]