javascripts/dashboard/LineChart.js

  1. import d3 from "~/javascripts/dashboard/d3_modules"
  2. import {
  3. targetLineValues, minAxisValues, axisTextValues, axisMeasurementValues, allowClicks
  4. } from "~/javascripts/dashboard/utils"
  5. /**
  6. * @class javascripts.dashboard.LineChart
  7. * @classdesc d3.js line chart for Netlify build times
  8. */
  9. export default class LineChart {
  10. constructor(selectedData, selectedContext, yKey, xKey, dateKey, timeFormat, wrapper, tooltip) {
  11. this.selectedData = selectedData
  12. this.selectedContext = selectedContext
  13. this.yKey = yKey
  14. this.xKey = xKey
  15. this.dateKey = dateKey
  16. this.timeFormat = timeFormat
  17. this.wrapper = d3.select(wrapper)
  18. this.tooltip = d3.select(tooltip)
  19. this.tooltipCircle = null
  20. this.touchEvent = false
  21. this.dv = {
  22. yAccessor: null,
  23. xAccessor: null,
  24. dateAccessor: null,
  25. deployIdAccessor: null,
  26. yScale: null,
  27. xScale: null,
  28. yAxisGenerator: null,
  29. xAxisGenerator: null,
  30. lineGenerator: null,
  31. bounds: null
  32. }
  33. this.dimensions = {
  34. width: parseInt(this.wrapper.style("width"), 10),
  35. height: 400,
  36. margin: {
  37. top: 15,
  38. right: 10,
  39. bottom: 40,
  40. left: 35,
  41. }
  42. }
  43. this.dimensions.boundedWidth = this.dimensions.width
  44. - this.dimensions.margin.left
  45. - this.dimensions.margin.right
  46. this.dimensions.boundedHeight = this.dimensions.height
  47. - this.dimensions.margin.top
  48. - this.dimensions.margin.bottom
  49. }
  50. /**
  51. * Called the first time the page is loaded to setup the visualisation
  52. *
  53. * @instance
  54. * @memberof javascripts.dashboard.LineChart
  55. **/
  56. createDataVis() {
  57. this.initialiseAccessors()
  58. this.setScales()
  59. this.setGenerators()
  60. this.initialiseDataVis()
  61. this.kpiLineTransition()
  62. }
  63. /**
  64. * When the data set is updated this will cause the visualisation to animate
  65. *
  66. * @instance
  67. * @memberof javascripts.dashboard.LineChart
  68. **/
  69. updateDataVis(selectedData, selectedContext) {
  70. this.selectedData = selectedData
  71. this.selectedContext = selectedContext
  72. const data = this.selectedData
  73. const updatedLine = this.dv.bounds.select("path").data(data)
  74. this.setScales()
  75. this.setGenerators()
  76. updatedLine.attr("d", this.dv.lineGenerator(data))
  77. .call(this.lineTransitionIn)
  78. this.dv.bounds.select(".line-chart--y-axis")
  79. .transition().duration(250)
  80. .call(this.dv.yAxisGenerator)
  81. this.dv.bounds.select(".line-chart--x-axis")
  82. .transition().duration(250)
  83. .call(this.dv.xAxisGenerator)
  84. this.kpiLineTransition()
  85. }
  86. /**
  87. * Setup the accessors for the visualisation
  88. *
  89. * @instance
  90. * @memberof javascripts.dashboard.LineChart
  91. **/
  92. initialiseAccessors() {
  93. this.dv.yAccessor = d => d[this.yKey]
  94. this.dv.xAccessor = d => d[this.xKey]
  95. this.dv.dateAccessor = d => new Date(d[this.dateKey])
  96. this.dv.deployIdAccessor = d => d.deploy_id
  97. }
  98. /**
  99. * Setup the scales for the visualisation which can be updated as the data set changes
  100. *
  101. * @instance
  102. * @memberof javascripts.dashboard.LineChart
  103. **/
  104. setScales() {
  105. this.dv.yScale = d3.scaleLinear()
  106. .domain([
  107. 0, d3.max(
  108. [minAxisValues(this.selectedContext), d3.max(this.selectedData.map(data => data[this.yKey]))
  109. ])
  110. ])
  111. .range([this.dimensions.boundedHeight, 0])
  112. .nice()
  113. this.dv.xScale = d3.scaleLinear()
  114. .domain(d3.extent(this.selectedData, this.dv.xAccessor))
  115. .range([0, this.dimensions.boundedWidth])
  116. }
  117. /**
  118. * Setup the generators for the visualisation which can be updated as the data set changes
  119. *
  120. * @instance
  121. * @memberof javascripts.dashboard.LineChart
  122. **/
  123. setGenerators() {
  124. this.dv.yAxisGenerator = d3.axisLeft()
  125. .scale(this.dv.yScale)
  126. this.dv.xAxisGenerator = d3.axisBottom()
  127. .scale(this.dv.xScale)
  128. if (this.selectedData.length < 10) {
  129. this.dv.xAxisGenerator = this.dv.xAxisGenerator
  130. .tickValues(this.selectedData.map(data => data[this.xKey]))
  131. .tickFormat(d3.format(".0f"))
  132. }
  133. // Duration line generator with animation
  134. this.dv.lineGenerator = d3.line()
  135. .x(d => this.dv.xScale(this.dv.xAccessor(d)))
  136. .y(d => this.dv.yScale(this.dv.yAccessor(d)))
  137. .curve(d3.curveMonotoneX)
  138. }
  139. /**
  140. * Setup the structure of the visualisation
  141. *
  142. * @instance
  143. * @memberof javascripts.dashboard.LineChart
  144. **/
  145. initialiseDataVis() {
  146. // Draw canvas
  147. const wrapperSvg = this.wrapper.append("svg")
  148. .attr("width", this.dimensions.width)
  149. .attr("height", this.dimensions.height)
  150. // Draw the bounds
  151. this.dv.bounds = wrapperSvg.append("g")
  152. .style("transform", `translate(${
  153. this.dimensions.margin.left
  154. }px, ${
  155. this.dimensions.margin.top
  156. }px)`)
  157. // Setup listener rect for mouse overs
  158. const listeningRect = this.dv.bounds.append("rect")
  159. .attr("class", "line-chart--listening-rect")
  160. .attr("width", this.dimensions.boundedWidth)
  161. .attr("height", this.dimensions.boundedHeight)
  162. .on("touchstart", this.onTouchStart, { passive: false })
  163. .on("mousemove touchmove", this.onMouseMove, { passive: true })
  164. .on("mouseleave touchend", this.onMouseLeave)
  165. .on("click", this.onClick)
  166. // Setup tooltip when mousing over the listener rect
  167. this.tooltipCircle = this.dv.bounds.append("circle")
  168. .attr("class", "line-chart--tooltip-circle")
  169. .attr("r", 4)
  170. // Setup the data visualisation graph line
  171. const line = this.dv.bounds.append("path")
  172. .attr("class", "line-chart--path")
  173. .attr("d", this.dv.lineGenerator(this.selectedData))
  174. .call(this.lineTransitionIn)
  175. // Setup axis
  176. const yAxis = this.dv.bounds.append("g")
  177. .attr("class", "line-chart--y-axis")
  178. .call(this.dv.yAxisGenerator)
  179. const xAxis = this.dv.bounds.append("g")
  180. .attr("class", "line-chart--x-axis")
  181. .call(this.dv.xAxisGenerator)
  182. .style("transform", `translateY(${
  183. this.dimensions.boundedHeight
  184. }px)`)
  185. .append("text")
  186. .attr("class", "line-chart--x-axis-label")
  187. .attr("x", this.dimensions.boundedWidth / 2)
  188. .attr("y", this.dimensions.margin.bottom - 5)
  189. .text(axisTextValues(this.selectedContext))
  190. // Setup KPI lines to start from zero on y axis. These will animate into position with kpiLineTransition
  191. const targetKpiLine = this.dv.bounds.append("line")
  192. .attr("class", "line-chart--success-line")
  193. .attr("x1", this.dv.xScale(this.dv.xAccessor(this.selectedData)))
  194. .attr("x2", this.dimensions.boundedWidth)
  195. .attr("y1", this.dv.yScale(0))
  196. .attr("y2", this.dv.yScale(0))
  197. const lowerBoundtKpiLine = this.dv.bounds.append("line")
  198. .attr("class", "line-chart--fail-line")
  199. .attr("x1", this.dv.xScale(this.dv.xAccessor(this.selectedData)))
  200. .attr("x2", this.dimensions.boundedWidth)
  201. .attr("y1", this.dv.yScale(0))
  202. .attr("y2", this.dv.yScale(0))
  203. }
  204. /**
  205. * Animate new line drawing in
  206. *
  207. * @instance
  208. * @memberof javascripts.dashboard.LineChart
  209. **/
  210. lineTransitionIn = path => {
  211. path.transition().duration(500)
  212. .attrTween("stroke-dasharray", this.tweenDashIn)
  213. }
  214. /**
  215. * Animate new line drawing in
  216. *
  217. * @instance
  218. * @memberof javascripts.dashboard.LineChart
  219. **/
  220. tweenDashIn() {
  221. const length = this.getTotalLength()
  222. const i = d3.interpolateString(`0, ${length}`, `${length}, ${length}`)
  223. return function (t) { return i(t) }
  224. }
  225. /**
  226. * Animate KPI lines
  227. *
  228. * @instance
  229. * @memberof javascripts.dashboard.LineChart
  230. **/
  231. kpiLineTransition() {
  232. const data = this.selectedData
  233. const lineValues = targetLineValues(this.selectedContext)
  234. this.dv.bounds.select(".line-chart--success-line")
  235. .transition().duration(250)
  236. .attr("x1", this.dv.xScale(this.dv.xAccessor(data)))
  237. .attr("x2", this.dimensions.boundedWidth)
  238. .attr("y1", this.dv.yScale(lineValues.successLineValue))
  239. .attr("y2", this.dv.yScale(lineValues.successLineValue))
  240. this.dv.bounds.select(".line-chart--fail-line")
  241. .transition().duration(250)
  242. .attr("x1", this.dv.xScale(this.dv.xAccessor(data)))
  243. .attr("x2", this.dimensions.boundedWidth)
  244. .attr("y1", this.dv.yScale(lineValues.failLineValue))
  245. .attr("y2", this.dv.yScale(lineValues.failLineValue))
  246. }
  247. /**
  248. * Prevent default on touch start which stops the entire page moving when a finger drags on the
  249. * interface. On touch screens the tool tip stays on the screen after dragging but a brief touch will
  250. * clear it.
  251. *
  252. * @instance
  253. * @memberof javascripts.dashboard.LineChart
  254. **/
  255. onTouchStart = event => {
  256. this.onMouseMove(event)
  257. event.preventDefault()
  258. }
  259. /**
  260. * Calculate which data point is selected on mouseover. Prevent when no builds present.
  261. *
  262. * @instance
  263. * @memberof javascripts.dashboard.LineChart
  264. **/
  265. onMouseMove = event => {
  266. if(this.selectedData[0].build_number === 0) return
  267. const closestDataset = this.closestDataPoint(event)
  268. const closestXValue = this.dv.xAccessor(closestDataset)
  269. const closestYValue = this.dv.yAccessor(closestDataset)
  270. const formatDate = d3.timeFormat(this.timeFormat)
  271. this.tooltip.select("[data-viz='date']")
  272. .text(formatDate(this.dv.dateAccessor(closestDataset)))
  273. this.tooltip.select("[data-viz='duration']")
  274. .html(`${closestYValue}${axisMeasurementValues(this.selectedContext)}`)
  275. const x = this.dv.xScale(closestXValue) + this.dimensions.margin.left
  276. const y = this.dv.yScale(closestYValue) + this.dimensions.margin.top
  277. this.tooltip.style("transform", `translate(`
  278. + `calc( -50% + ${x}px),`
  279. + `calc(-100% + ${y}px)`
  280. + `)`)
  281. this.tooltip.transition().duration(25)
  282. .style("opacity", 1)
  283. this.tooltipCircle.transition().duration(25)
  284. .attr("cx", this.dv.xScale(closestXValue))
  285. .attr("cy", this.dv.yScale(closestYValue))
  286. .style("opacity", 1)
  287. }
  288. /**
  289. * Handle mouse leave
  290. *
  291. * @instance
  292. * @memberof javascripts.dashboard.LineChart
  293. **/
  294. onMouseLeave = () => {
  295. this.tooltip.transition().duration(25)
  296. .style("opacity", 0)
  297. this.tooltipCircle.transition().duration(25)
  298. .style("opacity", 0)
  299. }
  300. /**
  301. * Open Netlify build logs for selected build. Prevent clicks when no builds present
  302. *
  303. * @instance
  304. * @memberof javascripts.dashboard.LineChart
  305. **/
  306. onClick = event => {
  307. if(this.selectedData[0].build_number === 0) return
  308. if(!allowClicks(this.selectedContext)) return
  309. const deployId = this.dv.deployIdAccessor(this.closestDataPoint(event))
  310. window.open(
  311. `https://app.netlify.com/sites/jeffreyknox/deploys/${deployId}`, '_blank'
  312. )
  313. }
  314. /**
  315. * Get the closest data point based on mouse position in listening rect. Also handles a touch event
  316. * for a finger dragging on the interface.
  317. *
  318. * @instance
  319. * @memberof javascripts.dashboard.LineChart
  320. **/
  321. closestDataPoint(event) {
  322. if (window.TouchEvent && event instanceof TouchEvent) event = event.touches[0];
  323. const mousePosition = d3.pointer(event, event.target)
  324. const hoveredDate = this.dv.xScale.invert(mousePosition[0])
  325. const getDistanceFromHoveredDate = d => Math.abs(this.dv.xAccessor(d) - hoveredDate)
  326. const closestIndex = d3.leastIndex(this.selectedData, (a, b) => (
  327. getDistanceFromHoveredDate(a) - getDistanceFromHoveredDate(b)
  328. ))
  329. return this.selectedData[closestIndex]
  330. }
  331. }