import d3 from "~/javascripts/dashboard/d3_modules"
import {
targetLineValues, minAxisValues, axisTextValues, axisMeasurementValues, allowClicks
} from "~/javascripts/dashboard/utils"
/**
* @class javascripts.dashboard.LineChart
* @classdesc d3.js line chart for Netlify build times
*/
export default class LineChart {
constructor(selectedData, selectedContext, yKey, xKey, dateKey, timeFormat, wrapper, tooltip) {
this.selectedData = selectedData
this.selectedContext = selectedContext
this.yKey = yKey
this.xKey = xKey
this.dateKey = dateKey
this.timeFormat = timeFormat
this.wrapper = d3.select(wrapper)
this.tooltip = d3.select(tooltip)
this.tooltipCircle = null
this.touchEvent = false
this.dv = {
yAccessor: null,
xAccessor: null,
dateAccessor: null,
deployIdAccessor: null,
yScale: null,
xScale: null,
yAxisGenerator: null,
xAxisGenerator: null,
lineGenerator: null,
bounds: null
}
this.dimensions = {
width: parseInt(this.wrapper.style("width"), 10),
height: 400,
margin: {
top: 15,
right: 10,
bottom: 40,
left: 35,
}
}
this.dimensions.boundedWidth = this.dimensions.width
- this.dimensions.margin.left
- this.dimensions.margin.right
this.dimensions.boundedHeight = this.dimensions.height
- this.dimensions.margin.top
- this.dimensions.margin.bottom
}
/**
* Called the first time the page is loaded to setup the visualisation
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
createDataVis() {
this.initialiseAccessors()
this.setScales()
this.setGenerators()
this.initialiseDataVis()
this.kpiLineTransition()
}
/**
* When the data set is updated this will cause the visualisation to animate
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
updateDataVis(selectedData, selectedContext) {
this.selectedData = selectedData
this.selectedContext = selectedContext
const data = this.selectedData
const updatedLine = this.dv.bounds.select("path").data(data)
this.setScales()
this.setGenerators()
updatedLine.attr("d", this.dv.lineGenerator(data))
.call(this.lineTransitionIn)
this.dv.bounds.select(".line-chart--y-axis")
.transition().duration(250)
.call(this.dv.yAxisGenerator)
this.dv.bounds.select(".line-chart--x-axis")
.transition().duration(250)
.call(this.dv.xAxisGenerator)
this.kpiLineTransition()
}
/**
* Setup the accessors for the visualisation
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
initialiseAccessors() {
this.dv.yAccessor = d => d[this.yKey]
this.dv.xAccessor = d => d[this.xKey]
this.dv.dateAccessor = d => new Date(d[this.dateKey])
this.dv.deployIdAccessor = d => d.deploy_id
}
/**
* Setup the scales for the visualisation which can be updated as the data set changes
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
setScales() {
this.dv.yScale = d3.scaleLinear()
.domain([
0, d3.max(
[minAxisValues(this.selectedContext), d3.max(this.selectedData.map(data => data[this.yKey]))
])
])
.range([this.dimensions.boundedHeight, 0])
.nice()
this.dv.xScale = d3.scaleLinear()
.domain(d3.extent(this.selectedData, this.dv.xAccessor))
.range([0, this.dimensions.boundedWidth])
}
/**
* Setup the generators for the visualisation which can be updated as the data set changes
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
setGenerators() {
this.dv.yAxisGenerator = d3.axisLeft()
.scale(this.dv.yScale)
this.dv.xAxisGenerator = d3.axisBottom()
.scale(this.dv.xScale)
if (this.selectedData.length < 10) {
this.dv.xAxisGenerator = this.dv.xAxisGenerator
.tickValues(this.selectedData.map(data => data[this.xKey]))
.tickFormat(d3.format(".0f"))
}
// Duration line generator with animation
this.dv.lineGenerator = d3.line()
.x(d => this.dv.xScale(this.dv.xAccessor(d)))
.y(d => this.dv.yScale(this.dv.yAccessor(d)))
.curve(d3.curveMonotoneX)
}
/**
* Setup the structure of the visualisation
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
initialiseDataVis() {
// Draw canvas
const wrapperSvg = this.wrapper.append("svg")
.attr("width", this.dimensions.width)
.attr("height", this.dimensions.height)
// Draw the bounds
this.dv.bounds = wrapperSvg.append("g")
.style("transform", `translate(${
this.dimensions.margin.left
}px, ${
this.dimensions.margin.top
}px)`)
// Setup listener rect for mouse overs
const listeningRect = this.dv.bounds.append("rect")
.attr("class", "line-chart--listening-rect")
.attr("width", this.dimensions.boundedWidth)
.attr("height", this.dimensions.boundedHeight)
.on("touchstart", this.onTouchStart, { passive: false })
.on("mousemove touchmove", this.onMouseMove, { passive: true })
.on("mouseleave touchend", this.onMouseLeave)
.on("click", this.onClick)
// Setup tooltip when mousing over the listener rect
this.tooltipCircle = this.dv.bounds.append("circle")
.attr("class", "line-chart--tooltip-circle")
.attr("r", 4)
// Setup the data visualisation graph line
const line = this.dv.bounds.append("path")
.attr("class", "line-chart--path")
.attr("d", this.dv.lineGenerator(this.selectedData))
.call(this.lineTransitionIn)
// Setup axis
const yAxis = this.dv.bounds.append("g")
.attr("class", "line-chart--y-axis")
.call(this.dv.yAxisGenerator)
const xAxis = this.dv.bounds.append("g")
.attr("class", "line-chart--x-axis")
.call(this.dv.xAxisGenerator)
.style("transform", `translateY(${
this.dimensions.boundedHeight
}px)`)
.append("text")
.attr("class", "line-chart--x-axis-label")
.attr("x", this.dimensions.boundedWidth / 2)
.attr("y", this.dimensions.margin.bottom - 5)
.text(axisTextValues(this.selectedContext))
// Setup KPI lines to start from zero on y axis. These will animate into position with kpiLineTransition
const targetKpiLine = this.dv.bounds.append("line")
.attr("class", "line-chart--success-line")
.attr("x1", this.dv.xScale(this.dv.xAccessor(this.selectedData)))
.attr("x2", this.dimensions.boundedWidth)
.attr("y1", this.dv.yScale(0))
.attr("y2", this.dv.yScale(0))
const lowerBoundtKpiLine = this.dv.bounds.append("line")
.attr("class", "line-chart--fail-line")
.attr("x1", this.dv.xScale(this.dv.xAccessor(this.selectedData)))
.attr("x2", this.dimensions.boundedWidth)
.attr("y1", this.dv.yScale(0))
.attr("y2", this.dv.yScale(0))
}
/**
* Animate new line drawing in
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
lineTransitionIn = path => {
path.transition().duration(500)
.attrTween("stroke-dasharray", this.tweenDashIn)
}
/**
* Animate new line drawing in
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
tweenDashIn() {
const length = this.getTotalLength()
const i = d3.interpolateString(`0, ${length}`, `${length}, ${length}`)
return function (t) { return i(t) }
}
/**
* Animate KPI lines
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
kpiLineTransition() {
const data = this.selectedData
const lineValues = targetLineValues(this.selectedContext)
this.dv.bounds.select(".line-chart--success-line")
.transition().duration(250)
.attr("x1", this.dv.xScale(this.dv.xAccessor(data)))
.attr("x2", this.dimensions.boundedWidth)
.attr("y1", this.dv.yScale(lineValues.successLineValue))
.attr("y2", this.dv.yScale(lineValues.successLineValue))
this.dv.bounds.select(".line-chart--fail-line")
.transition().duration(250)
.attr("x1", this.dv.xScale(this.dv.xAccessor(data)))
.attr("x2", this.dimensions.boundedWidth)
.attr("y1", this.dv.yScale(lineValues.failLineValue))
.attr("y2", this.dv.yScale(lineValues.failLineValue))
}
/**
* Prevent default on touch start which stops the entire page moving when a finger drags on the
* interface. On touch screens the tool tip stays on the screen after dragging but a brief touch will
* clear it.
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
onTouchStart = event => {
this.onMouseMove(event)
event.preventDefault()
}
/**
* Calculate which data point is selected on mouseover. Prevent when no builds present.
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
onMouseMove = event => {
if(this.selectedData[0].build_number === 0) return
const closestDataset = this.closestDataPoint(event)
const closestXValue = this.dv.xAccessor(closestDataset)
const closestYValue = this.dv.yAccessor(closestDataset)
const formatDate = d3.timeFormat(this.timeFormat)
this.tooltip.select("[data-viz='date']")
.text(formatDate(this.dv.dateAccessor(closestDataset)))
this.tooltip.select("[data-viz='duration']")
.html(`${closestYValue}${axisMeasurementValues(this.selectedContext)}`)
const x = this.dv.xScale(closestXValue) + this.dimensions.margin.left
const y = this.dv.yScale(closestYValue) + this.dimensions.margin.top
this.tooltip.style("transform", `translate(`
+ `calc( -50% + ${x}px),`
+ `calc(-100% + ${y}px)`
+ `)`)
this.tooltip.transition().duration(25)
.style("opacity", 1)
this.tooltipCircle.transition().duration(25)
.attr("cx", this.dv.xScale(closestXValue))
.attr("cy", this.dv.yScale(closestYValue))
.style("opacity", 1)
}
/**
* Handle mouse leave
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
onMouseLeave = () => {
this.tooltip.transition().duration(25)
.style("opacity", 0)
this.tooltipCircle.transition().duration(25)
.style("opacity", 0)
}
/**
* Open Netlify build logs for selected build. Prevent clicks when no builds present
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
onClick = event => {
if(this.selectedData[0].build_number === 0) return
if(!allowClicks(this.selectedContext)) return
const deployId = this.dv.deployIdAccessor(this.closestDataPoint(event))
window.open(
`https://app.netlify.com/sites/jeffreyknox/deploys/${deployId}`, '_blank'
)
}
/**
* Get the closest data point based on mouse position in listening rect. Also handles a touch event
* for a finger dragging on the interface.
*
* @instance
* @memberof javascripts.dashboard.LineChart
**/
closestDataPoint(event) {
if (window.TouchEvent && event instanceof TouchEvent) event = event.touches[0];
const mousePosition = d3.pointer(event, event.target)
const hoveredDate = this.dv.xScale.invert(mousePosition[0])
const getDistanceFromHoveredDate = d => Math.abs(this.dv.xAccessor(d) - hoveredDate)
const closestIndex = d3.leastIndex(this.selectedData, (a, b) => (
getDistanceFromHoveredDate(a) - getDistanceFromHoveredDate(b)
))
return this.selectedData[closestIndex]
}
}