#
# 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()