KamaKAzii
3/12/2017 - 11:27 PM

boom.coffee

#
# How to use params:
#
# lineDataArray: [<object>]
# An array of objects with the following structure:
# [
#   {
#     data: { x, value }
#     color: <string>(optional) For attr("stroke", ...)
#     style: <string>(optional) "dashed" || "solid" (default: "solid")
#     width: <float>(optional)  For attr("stroke-width", ...) (default: 1.5)
#   }
# ]
#
# 
# steppedXAxisScaleData: <object>
# Useful when you want the X Axis to rescale the graph so one portion is given
# more visual real estate than the other.
#
# {
#   step: <float>   The proportion of the width at which the graph's
#                   domain/range changes.
#   value: <date>   The value in y-axis which should correspond
#                   the stepPoint.
# }
#
#
# yAxisValuePad: <object>
# Use to pad out the top and bottom of the yAxis; useful if you want the min and
# max values to sit off from the edge. It is a multiplier of y axes' (max - min).
# i.e. if max - min == 100, then a topProportion of 0.1 would add 10 pixels
# to the top.
#
# {
#   topProportion: <float>
#   bottomProportion: <float>
# }
#
#
# xAxisTickColors && yAxisTickColors <object>
# Set custom colours for tick lines/text.
# {
#   text: <string>
#   line: <string>
# }
#
#
# horizontalBandData: [<object>]
# If you want to draw horizontal bands across the graph.
# Will be passed into the scale to get relevant pixel value,
# i.e. start and end should be same type as y-axis.
#
# [
#   {
#     start: <date>
#
#     end: <date>                     Will be converted with the xAxisDateToWidth scale.
#                                 OR
#     width: <float>                  A plain pixel width.
#
#     solid: [<string>, <float>]      Of the form [color, opacity].
#                                 OR
#     gradient: [                     For a gradient from left to right.
#       [<string>, <float>],
#       [<string>, <float>]
#     ]
#   }
# ]
#
#
# highlightValueData: [<object>]
# If you want to permenantly highlight a value (e.g. like the current value in
# the goals graph) this will place a dot with a label and value at the corresponding
# point.
#
# [
#   {
#     label: <string>
#     value: <string>
#     point: [<x-val>, <y-val>]     These will be converted via relevant axis scales.
#   }
# ]
#
#
# yAxisMarkersData: [<object>]
# Useful if you want to highlight a value on the y-axis with a marker
# e.g the final $ value on a goal.
# 
# [
#   {
#     label: <string>
#     yValue: <y-val>              Will be converted via yAxisScale.
#   }
# ]

angular.module("personal-banker").directive "d3MultiLineGraph", (env, Colors, $filter) ->
  restrict: "E"
  template: """
    <div class="d3-multi-line-graph" style="width: 100%; height: 100%">
      <div class="dmlg__content-wrapper" style="width: 100%; height: 100%">
        <svg width="100%" height="100%"></svg>
      </div>
    </div>
  """
  scope:
    lineDataArray: "=dmlgLineDataArray"
    steppedXAxisScaleData: "=dmglSteppedXAxisScaleData"
    xAxisTickColors: "=dmglXAxisTickColors"
    yAxisTickColors: "=dmglYAxisTickColors"
    yAxisValuePad: "=dmglYAxisValuePad"
    horizontalBandData: "=dmglHorizontalBandData"
    highlightValueData: "=dmglHighlightValueData"
    yAxisMarkersData: "=dmglYAxisMarkersData"

  link: (scope, elem, attrs) ->

    elem.css({ width: "100%", height: "100%" })

    $svg                    = elem.find("svg")
    svg                     = d3.select $svg.get(0)
    width                   = $svg.width()
    height                  = $svg.height()

    lineDataArray           = scope.lineDataArray
    xStepData               = scope.steppedXAxisScaleData
    xAxisTickColors         = {}
    horizontalBandData      = []
    highlightValueData      = []
    yAxisMarkersData        = []
    yAxisValuePad           = scope.yAxisValuePad
    yAxisValuePadTop        = 0
    yAxisValuePadBottom     = 0

    # Helper functions.
    #
    #
    #
    
    getAllLinesDataXMin = ->
      d3.min(_.map lineDataArray, (line) -> d3.min(line.data, (d) -> new Date(d.x)))

    getAllLinesDataXMax = ->
      d3.max(_.map lineDataArray, (line) -> d3.max(line.data, (d) -> new Date(d.x)))

    getAllLinesDataYMin = ->
      d3.min(_.map lineDataArray, (line) -> d3.min(line.data, (d) -> d.value))

    getAllLinesDataYMax = ->
      d3.max(_.map lineDataArray, (line) -> d3.max(line.data, (d) -> d.value))

    getValuesFromTransformString = (string) ->
      _.map string.split("(")[1].split(")")[0].split(","), (v) -> parseFloat(v.trim())

    # Transform necessary initial values.
    #
    #
    #

    formattedTimeScaleData = [getAllLinesDataXMin(), getAllLinesDataXMax()]

    if yAxisValuePad
      absDelta = Math.abs(getAllLinesDataYMax() - getAllLinesDataYMin())
      yAxisValuePadTop = absDelta * yAxisValuePad.topProportion
      yAxisValuePadBottom = absDelta * yAxisValuePad.bottomProportion

    if scope.xAxisTickColors
      xAxisTickColors = scope.xAxisTickColors
    else
      xAxisTickColors.text = Colors.getColorTextLight()
      xAxisTickColors.line = Colors.getColorTextLight()

    if scope.yAxisTickColors
      yAxisTickColors = scope.yAxisTickColors
    else
      yAxisTickColors.text = Colors.getColorTextLight()
      yAxisTickColors.line = Colors.getColorTextLight()

    if scope.horizontalBandData then horizontalBandData = scope.horizontalBandData
    if scope.highlightValueData then highlightValueData = scope.highlightValueData
    if scope.yAxisMarkersData then yAxisMarkersData = scope.yAxisMarkersData

    # Build scales.
    #
    #
    #
 
    xAxisScale = d3.scaleTime()

    if xStepData
      xAxisScale
        .domain([
          d3.min(formattedTimeScaleData)
          xStepData.value
          d3.max(formattedTimeScaleData)
        ])
        .range([0, width * xStepData.step, width])
    else
      xAxisScale
        .domain([d3.min(formattedTimeScaleData), d3.max(formattedTimeScaleData)])
        .range([0, width])

    yAxisScale = d3.scaleLinear()
      .domain([
        getAllLinesDataYMin() - yAxisValuePadBottom
        getAllLinesDataYMax() + yAxisValuePadTop
      ])
      .range([height, 0])

    # Rendering horizontal bands.
    #
    #
    #

    horizontalBands = []
    horizontalBandsGroup = null

    if horizontalBandData.length > 0
      horizontalBandsGroup = svg.append("g")
        .attr("class", "horizontal-band-group")

    angular.forEach horizontalBandData, (b) ->
      bandX1 = 0
      bandX2 = 0
      if b.end
        bandX1 = xAxisScale(b.start)
        bandX2 = xAxisScale(b.end)
      else if b.width
        bandX1 = xAxisScale(b.start)
        bandX2 = bandX1 + b.width
      bandWidth = bandX2 - bandX1

      band = horizontalBandsGroup.append("rect")
        .attr("width", bandWidth)
        .attr("height", height)
        .attr("x", bandX1)

      if b.solid
        band
          .attr("fill", b.solid[0])
          .attr("fill-opacity", b.solid[1])
      else if b.gradient
        gradient = svg.append("defs")
          .append("linearGradient")
          .attr("spreadMethod", "pad")
          .attr(
            "id",
            "#{b.gradient[0][0].substr(1)}#{b.gradient[1][0].substr(1)}"
          )
        gradient.append("stop")
          .attr("offset", "0%")
          .attr("stop-color", b.gradient[0][0])
          .attr("stop-opacity", b.gradient[0][1])
        gradient.append("stop")
          .attr("offset", "100%")
          .attr("stop-color", b.gradient[1][0])
          .attr("stop-opacity", b.gradient[1][1])

        band
          .style(
            "fill",
            "url(##{b.gradient[0][0].substr(1)}#{b.gradient[1][0].substr(1)})"
          )

      horizontalBands.push(band)

    # Rendering the lines.
    #
    #
    #

    linesGroup = svg.append("g")
      .attr("class", "lines-group")

    line = d3.line()
      .x (d) -> xAxisScale(new Date(d.x))
      .y (d) -> yAxisScale(d.value)
    
    linePaths = []
    angular.forEach lineDataArray, (l) ->
      path = linesGroup.append("path")
        .datum(l.data)
        .attr("fill", "none")
        .attr("stroke", l.color || Colors.getColorThemePrimary())
        .attr("stroke-linejoin", "round")
        .attr("stroke-linecap", "round")
        .attr("stroke-width", l.width || 1.5)
        .attr("d", line)

      if l.style is "dashed" then path.attr("stroke-dasharray", "10, 5")

      linePaths.push(path)

    # Rendering the Y Axis.
    # NOTE: Actually a d3.axisRight pushed to the left
    #
    #
    #
    
    yAxis = d3.axisRight()
      .scale(yAxisScale)
      .tickValues([
        getAllLinesDataYMax()
        (getAllLinesDataYMin() + getAllLinesDataYMax()) / 2
      ])
      .tickFormat(d3.format("$.2f"))


    yAxisGroup = svg
      .append("g")
      .attr("class", "y-axis-group")

    yAxisElements = yAxisGroup
      .call(yAxis)

    yAxisElements.select(".domain")
      .remove()
      
    yAxisElements.selectAll("g.tick line")
      .attr("stroke", yAxisTickColors.line)

    yAxisElements.selectAll("g.tick text")
      .style("font-size", "12px")
      .style("font-weight", "bold")
      .attr("tranform", "translateY(2)")
      .attr("fill", yAxisTickColors.text)

    yAxisGroupBBox = yAxisGroup.node().getBBox()
    yAxisGroup
      .attr("transform", "translate(0, 0)")

    # Rendering the X Axis.
    #
    #

    xAxis = d3.axisBottom()
      .scale(xAxisScale)
      .ticks(d3.timeMonth.every(1))
      .tickSize(10)
      .tickFormat(d3.timeFormat("%b %y"))

    xAxisGroup = svg
      .append("g")
      .attr("class", "x-axis-group")
    xAxisElements = xAxisGroup
      .call(xAxis)

    xAxisElements.select(".domain")
      .remove()

    xAxisElements.selectAll("g.tick line")
      .attr("transform", "translate(0, 12)")
      .attr("stroke", xAxisTickColors.line)

    xAxisElements.selectAll("g.tick text")
      .attr("transform", "translate(0, -12)")
      .attr("fill", xAxisTickColors.text)

    xAxisElements.selectAll(".tick text")
      .style("text-anchor", "start")

    xAxisElements.selectAll(".tick:last-child text")
      .style("text-anchor", "end")

    xAxisGroupBBox = xAxisGroup.node().getBBox()
    xAxisGroup
      .attr("transform", "translate(0, #{height - xAxisGroupBBox.height})")

    # Rendering highlight values.
    #
    #
    #

    highlightValuesGroup = null
    if highlightValueData.length > 0
      highlightValuesGroup = svg.append("g")
        .attr("class", "highlight-values")

    angular.forEach highlightValueData, (h) ->
      coord = [
        xAxisScale(h.point[0])
        yAxisScale(h.point[1])
      ]

      hWrapperGroup = highlightValuesGroup.append("g")
        .attr("class", "highlight-value")
        .attr("transform", "translate(#{coord[0]}, #{coord[1]})")

      hDot = hWrapperGroup.append("circle")
        .attr("r", 6)
        .attr("fill", "#ffffff")
        .attr("stroke", Colors.getColorThemePrimary())
        .attr("stroke-width", 4)

      hTextGroup = hWrapperGroup.append("g")
        .attr("class", "highlight-text-wrapper")

      hLabel = hTextGroup.append("text")
        .text(h.label)
        .attr("x", 25)
        .attr("y", 25)
        .attr("fill", Colors.getColorTextLight())
        .style("font-size", "12px")

      hValue = hTextGroup.append("text")
        .text(h.value)
        .attr("x", 25)
        .attr("y", 48)
        .attr("fill", Colors.getColorThemePrimary())
        .style("font-size", "20px")

      # Get box and transform if necessary.
      b = hTextGroup.node().getBBox()
      leeway = 20
      xTranslate = 0
      yTranslate = 0
      anchorToEnd = false

      if xAxisScale(h.point[0]) + b.width > width - leeway
        xTranslate = -(b.width - 20)
        anchorToEnd = true
      if yAxisScale(h.point[1]) + b.height > height - leeway
        yTranslate = -(b.height + 30)

      hTextGroup
        .attr("transform", "translate(#{xTranslate}, #{yTranslate})")

      if anchorToEnd
        hTextGroup
          .selectAll("text").attr("text-anchor", "end")

      # Refresh box after transforming.
      b = hTextGroup.node().getBBox()
      bgPadTop = 6
      bgPadLeft = 12
      hTextGroup.insert("rect", ":first-child")
        .attr("rx", 6)
        .attr("ry", 6)
        .attr("x", b.x - bgPadLeft)
        .attr("y", b.y - bgPadTop)
        .attr("width", b.width + (bgPadLeft * 2))
        .attr("height", b.height + (bgPadTop * 2))
        .attr("fill", "#ffffff")
        .attr("fill-opacity", 0.9)

    # Rendering Y Axis Markers.
    #
    #
    #

    yAxisMarkersGroup = null
    if yAxisMarkersData.length > 0
      yAxisMarkersGroup = svg.append("g")
        .attr("class", "y-axis-markers")

    angular.forEach yAxisMarkersData, (m) ->
      coord = [
        xAxisScale(getAllLinesDataXMax())
        yAxisScale(m.yValue)
      ]

      mWrapperGroup = yAxisMarkersGroup.append("g")
        .attr("class", "y-axis-marker")
        .attr("transform", "translate(#{coord[0] - 12}, #{coord[1] + 4})")

      mContentGroup = mWrapperGroup.append("g")
        .attr("class", "y-axis-marker-wrapper")

      mLabel = mContentGroup.append("text")
        .text(m.label)
        .attr("fill", "#ffffff")
        .attr("text-anchor", "end")
        .style("font-size", "13px")
        .style("font-weight", "bold")

      triangleLineData = [
        { x: 0, y: 0 }
        { x: 0, y: 10 }
        { x: 5, y: 5 }
      ]
      triangleLineFunc = d3.line()
        .x (d) -> d.x
        .y (d) -> d.y
        .curve(d3.curveLinear)
      mTriangle = mWrapperGroup.append("path")
        .attr("d", triangleLineFunc(triangleLineData))
        .attr("transform", "translate(6, -9)")
        .attr("fill", Colors.getColorThemePrimary())
        .attr("fill-opacity", 0.8)

      b = mContentGroup.node().getBBox()
      bgPadTop = 3
      bgPadLeft = 6
      mContentGroup.insert("rect", ":first-child")
        .attr("rx", 3)
        .attr("ry", 3)
        .attr("x", b.x - bgPadLeft)
        .attr("y", b.y - bgPadTop)
        .attr("width", b.width + (bgPadLeft * 2))
        .attr("height", b.height + (bgPadTop * 2))
        .attr("fill", Colors.getColorThemePrimary())
        .attr("fill-opacity", 0.8)

    # Add events for tooltips.
    #
    #
    #

    updateTooltip = ->
      mCoords             = d3.mouse(this)
      xValue              = xAxisScale.invert(mCoords[0])
      yValue              = yAxisScale.invert(mCoords[1])
      scaledValue         = [xValue, yValue]
      targetTooltipCoord  = [null, null]
      targetLines         = []

      angular.forEach lineDataArray, (a) ->
        extents = d3.extent(a.data, (d) -> d.x)
        if extents[0] < scaledValue[0] < extents[1]
          targetLines.push a

      unless targetLines.length > 0 then return

      potentialLinesAndIndex = []
      angular.forEach targetLines, (l) ->
        xValuesArray = _.map l.data, (d) -> d.x
        potentialLinesAndIndex.push({
          i: d3.bisectLeft(xValuesArray, scaledValue[0])
          line: l
        })

      target = _.sortBy(potentialLinesAndIndex, (d) ->
        index = d.i
        dataArray = d.line.data
        data = dataArray[index]
        p = [xAxisScale(data.x), yAxisScale(data.value)]
        return Math.abs(mCoords[1] - p[1])
      )[0]

      tValue = target.line.data[target.i]
      tPoint = [xAxisScale(tValue.x), yAxisScale(tValue.value)]

      $cWrapper = elem.find(".dmlg__content-wrapper")
      $t = $cWrapper.find(".dmlg__tooltip-wrapper")

      if $t.length is 0
        $t = $("<div>")
          .addClass("dmlg__tooltip-wrapper")
          .appendTo($cWrapper)
        $iw = $("<div>")
          .addClass("dmlg__tooltip-inner-wrapper")
          .appendTo($t)
        $("<div>")
          .addClass("dmlg__tooltip-dot")
          .appendTo($t)
        $("<div>")
          .addClass("dmlg__tooltip-stripe")
          .appendTo($iw)
        $("<div>")
          .addClass("dmlg__tooltip-label")
          .appendTo($iw)
        $("<div>")
          .addClass("dmlg__tooltip-value")
          .appendTo($iw)

      $t.css({ "left": "#{tPoint[0]}px","top": "#{tPoint[1]}px" })

      $t.find(".dmlg__tooltip-label")
        .html(d3.timeFormat("%b %d")(tValue.x))
      $t.find(".dmlg__tooltip-value")
        .html($filter("currency")(tValue.value))
      $t.find(".dmlg__tooltip-stripe")
        .css("background", target.line.color)
      $t.find(".dmlg__tooltip-value")
        .css("color", target.line.color)

      $iw = $t.find(".dmlg__tooltip-inner-wrapper")
      if tPoint[0] + $iw.outerWidth() > width - 20
        $iw.css("transform", "translateX(#{-$iw.outerWidth() - 20}px)")
      else
        $iw.css("transform", "translateX(0px)")

    svg.on("mousemove", updateTooltip)