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] }}