KamaKAzii
3/13/2017 - 7:19 AM

yeahhhhbuddy.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 style="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) ->

    scope.init = ->
      elem.css({ width: "100%", height: "100%" })

      scope.render()

      scope.$on "resizeCheck:window", -> scope.render()

    scope.render = ->
      $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())

      # Make render() idempotent.
      #
      #
      #

      $svg.find("*").remove()

      # 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.
      #
      #
      #


      addOrUpdateTooltip = ->
        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)]

        # TODO: Make this a part of the directive controlled by
        # things like ng-if. Duh...
        $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)
        $t.find(".dmlg__tooltip-dot")
          .css("background", target.line.color)

        $iw = $t.find(".dmlg__tooltip-inner-wrapper")
        translateX = 0
        translateY = 0
        if tPoint[0] + $iw.outerWidth() > width - 20
          translateX = -$iw.outerWidth() - 20

        if tPoint[1] + $iw.outerHeight() > height - 20
          translateY = -$iw.outerHeight() - 20
          $iw.addClass("bottom")
        else
          $iw.removeClass("bottom")

        $iw.css("transform", "translate(#{translateX}px, #{translateY}px)")


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

      svg.append("rect")
        .attr("class", "mouse-event-rect")
        .attr("fill", "none")
        .attr("width", width)
        .attr("height", height)
        .style("pointer-events", "all")
        .on("mousemove", addOrUpdateTooltip)
        .on("mouseout", removeTooltip)

    scope.init()