|
|
|
// A good understanding of important D3 concepts (selections, scales, transitions, etc.) is helpful to understand this code |
|
// see: https://github.com/mbostock/d3/wiki/Tutorials |
|
|
|
// follows the reusable chart convention - see http://bost.ocks.org/mike/chart/ |
|
|
|
function JourneysChart() { |
|
|
|
// assumptions about the data passed to this chart: |
|
// i.e. dataset of each selection |
|
// -> list of objects. each object has the following keys: |
|
// -> `letters` an Array of characters from the journey string, order preserved |
|
// -> `conversionRate` a Float between 0.0001 and 1 (in practice, 0.1) |
|
// -> `sessionCount` an Integer between 1 and 100000 |
|
|
|
// |
|
// DEFAULT CONFIGURATION PARAMETERS |
|
// |
|
|
|
var margin = {top: 30, bottom: 30, left: 50, right: 50} |
|
var totalHeight = 700 |
|
var spaceBetweenYAxisAndChart = 40 |
|
var minBarHeight = 5 |
|
var maxBarHeight = 50 |
|
var viewportWidth = 20 |
|
var barTipLength = 5 |
|
var maxScalingFactor = 400 |
|
|
|
var legendTitle = 'Channels touched' |
|
var legendWidth = 120 |
|
var legendXPos = 2 |
|
var legendYPos = 175 |
|
var legendKeyHeight = 13 |
|
var legendKeyWidth = 20 |
|
var spaceBetweenLegendKeys = 3 |
|
var spaceBetweenLegendTitleAndKeys = 20 |
|
var spaceBetweenSwatchAndText = 5 |
|
|
|
// |
|
// CONFIGURATION PARAMETERS - calculated programmatically |
|
// |
|
|
|
var height = totalHeight - margin.top - margin.bottom |
|
|
|
// |
|
// SCALES - that don't depend on data |
|
// |
|
|
|
var yScaleOnAxis = d3.scale.linear() |
|
.domain([0, 1]) |
|
.range([height, 0]) |
|
|
|
var yScaleOnChart = d3.scale.linear() |
|
.domain(yScaleOnAxis.domain()) |
|
.range([height, 0]) |
|
|
|
var yAxis = d3.svg.axis() |
|
.scale(yScaleOnAxis) |
|
.orient('left') |
|
.ticks(2) |
|
.tickSize(5, 0) |
|
.tickPadding(10) |
|
.tickFormat(d3.format('%')) |
|
|
|
// |
|
// returned function that draws the whole chart |
|
// |
|
|
|
function drawChart(selection) { |
|
selection.each(function(dataset, i) { |
|
|
|
// |
|
// CONFIGURATION PARAMETERS - calculated programmatically |
|
// |
|
|
|
var containerWidth = this.clientWidth |
|
var isScreenSmall = window.innerWidth < 550 |
|
var totalWidth = isScreenSmall ? containerWidth : containerWidth - legendWidth - 5// bit of a hack to make legend go to left when screen big enough |
|
var width = totalWidth - margin.left - margin.right |
|
|
|
// |
|
// SCALES - that don't depend on data |
|
// |
|
|
|
var journeyStepXPos = d3.scale.ordinal() |
|
.domain(d3.range(dataset.longestJourneyLength)) |
|
.rangeRoundBands([0, width], 0.1, 0) |
|
|
|
var touchpointToColorScale = d3.scale.ordinal() |
|
.domain(dataset.touchpoints.map(function(d) { return d.key })) |
|
.range(colorbrewer.Dark2[dataset.touchpoints.length]) |
|
|
|
var countToBarHeightScale = d3.scale.linear() |
|
.domain([1, d3.max(dataset.values, function(d) { return d.sessionCount })]) |
|
.range([minBarHeight, maxBarHeight]) |
|
|
|
// |
|
// LEGEND |
|
// |
|
|
|
// draw legend before chart so that it can be floated left |
|
|
|
// legend is selected at each chart update in order to update its keys |
|
var legend = d3.select(this).selectAll('svg.legend') |
|
.data(function(dataset) { return [dataset.touchpoints] }) |
|
|
|
legendEnter = legend.enter() |
|
.append('svg') |
|
.classed('legend', true) |
|
.attr('width', legendWidth) |
|
|
|
legendEnter |
|
.append('text') |
|
.text(legendTitle) |
|
.attr('alignment-baseline', 'before-edge') |
|
|
|
legendEnter |
|
.append('g') |
|
.classed('legend keys', true) |
|
.attr('transform', 'translate(' + 1 // to align with title |
|
+ ',' + spaceBetweenLegendTitleAndKeys + ')') |
|
|
|
var legendKeys = legend.select('g.legend.keys').selectAll('g.legend.key') |
|
.data(function(d) { return d }, function(d) { return d.value }) |
|
|
|
var legendKeysEnter = legendKeys.enter() |
|
.append('g') |
|
.classed('legend key', true) |
|
.attr('transform', function(d,i) { |
|
return 'translate(' + 0 + ',' + (i * (legendKeyHeight + spaceBetweenLegendKeys)) + ')' } |
|
) |
|
|
|
legendKeysEnter |
|
.append('rect') |
|
.attr('width', legendKeyWidth) |
|
.attr('height', legendKeyHeight) |
|
.style('fill', function(d) { return touchpointToColorScale(d.key) }) |
|
|
|
legendKeysEnter |
|
.append('text') |
|
.text(function(d) { return d.value }) |
|
.classed('label', true) |
|
.style('alignment-baseline', 'central') |
|
.attr('x', legendKeyWidth + spaceBetweenSwatchAndText) |
|
.attr('y', legendKeyHeight/2) |
|
|
|
legendKeysEnter |
|
.append('title') |
|
.text(function(d) { return d.key }) |
|
|
|
legendKeys.exit() |
|
.remove() |
|
|
|
// |
|
// THE WHOLE CHART - includes y axis (but not legend) |
|
// |
|
|
|
// each svg.chart element is bound to one dataset |
|
// in practice there is one dataset we are passing to the chart, so only one svg will be created, and then updated when new data is passed |
|
var wholeChart = d3.select(this).selectAll('svg.chart') |
|
.data(function(dataset) { |
|
return [dataset.values.sort(function(a,b) { return b.sessionCount - a.sessionCount })] |
|
}) // sort so that the smallest bars are drawn last and therefore on top of larger ones |
|
|
|
var wholeChartEnter = wholeChart.enter() |
|
.append('svg') |
|
.classed('chart', true) |
|
.attr('width', totalWidth) |
|
.attr('height', totalHeight) |
|
|
|
// |
|
// MAIN CHART |
|
// |
|
|
|
wholeChartEnter |
|
.append('g') |
|
.classed('innerchart', true) |
|
.attr('transform', 'translate(' + (margin.left + spaceBetweenYAxisAndChart) + ',' + margin.top + ')') |
|
|
|
var innerchart = wholeChart.select('g.innerchart') |
|
|
|
// |
|
// Y AXIS |
|
// |
|
|
|
var yAxisG = wholeChartEnter |
|
.append('g') |
|
.classed('y axis', true) |
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') |
|
.call(yAxis) |
|
|
|
yAxisG |
|
.append('text') |
|
.classed('label', true) |
|
.text('Conversion rate') |
|
.style('text-anchor', 'end') |
|
.attr('transform', 'rotate(270)') |
|
.attr('y', -30) |
|
.attr('x', -20) |
|
|
|
// |
|
// VIEWPORT - brush that enables the zoom and scroll effect |
|
// |
|
|
|
var viewport = d3.svg.brush() |
|
.y(yScaleOnAxis) |
|
.on('brush', function() { // 'brush' is D3 terminology for click-and-dragging the viewport on the y axis |
|
|
|
// if viewport isn't present, change scale of chart to the scale of y axis |
|
// otherwise change scale of chart to the range covered by the viewport on the y axis |
|
// reading about D3 Scales would help to understand, for example: http://bost.ocks.org/mike/bar/#scaling |
|
yScaleOnChart.domain(viewport.empty() ? yScaleOnAxis.domain() : viewport.extent()) |
|
drawInnerChart({ barTransitionDuration: 50 }) // very short duration to make zoom & scroll smooth |
|
|
|
links |
|
.transition() |
|
.duration(50) |
|
.attr('y2', function(d) { return yScaleOnChart(d.conversionRate) }) |
|
|
|
}) |
|
.on('brushend', function() { |
|
|
|
zoomListener.y(yScaleOnChart) |
|
|
|
// update scaleExtent otherwise can't zoom back out with mouse in innnerchart |
|
var fullDomainExtent = yScaleOnAxis.domain()[1] - yScaleOnAxis.domain()[0] |
|
var currentDomainExtent = yScaleOnChart.domain()[1] - yScaleOnChart.domain()[0] |
|
|
|
var minScale = currentDomainExtent / fullDomainExtent |
|
var maxScale = minScale * maxScalingFactor |
|
|
|
zoomListener.scaleExtent([minScale, maxScale]) |
|
|
|
}) |
|
|
|
var viewportElement = yAxisG |
|
.append('g') |
|
.classed('viewport', true) |
|
.call(viewport.extent(yScaleOnAxis.domain())) |
|
|
|
viewportElement |
|
.selectAll('rect') |
|
.attr('x', -viewportWidth) |
|
.attr('width', viewportWidth) |
|
.style('fill', 'gray') |
|
.style('opacity', 0.5) |
|
|
|
// |
|
// ARROWS |
|
// |
|
|
|
var numPixelsOnChartMovedByArrow = 20 |
|
|
|
yAxisG.append('polygon') |
|
.attr('transform', 'translate(' + (-10) + ',' + (-5) + ')') |
|
.attr('points', '-10,0 0,-10 10,0') |
|
.on('click', function() { |
|
moveViewportByXPixelsOnChart('up', numPixelsOnChartMovedByArrow) |
|
}) |
|
|
|
yAxisG.append('polygon') |
|
.attr('transform', 'translate(' + (-10) + ',' + (5 + height) + ')') |
|
.attr('points', '-10,0 0,10 10,0') |
|
.on('click', function() { |
|
moveViewportByXPixelsOnChart('down', numPixelsOnChartMovedByArrow) |
|
}) |
|
|
|
function moveViewportByXPixelsOnChart(direction, numPixels) { |
|
|
|
switch (direction) { |
|
case 'up': // move viewport up |
|
directionFunc = function(d, offset) { return d+offset } |
|
break |
|
case 'down': // move viewport down |
|
directionFunc = function(d, offset) { return d-offset } |
|
break |
|
default: |
|
directionFunc = function(d) { return d } |
|
} |
|
|
|
var offset = yScaleOnChart.invert(0) - yScaleOnChart.invert(numPixels) |
|
|
|
var newViewportExtent = viewport.extent().map(function(d) { |
|
return directionFunc(d, offset) |
|
}) |
|
|
|
if (newViewportExtent[1] >= yScaleOnAxis.domain()[1] || newViewportExtent[0] <= yScaleOnAxis.domain()[0]) { |
|
// TODO still move it but only by what is left to close the gap |
|
return |
|
} |
|
|
|
viewportElement |
|
.call(viewport.extent(newViewportExtent)) // move viewport rectangle |
|
.call(viewport.event) // send brush events so that chart updates too |
|
|
|
} |
|
|
|
// |
|
// ZOOM ON CHART |
|
// |
|
|
|
var zoomListener = d3.behavior.zoom() |
|
.y(yScaleOnChart) |
|
.scaleExtent([1, maxScalingFactor]) // do not change the first value, must always be 1; last value can be changed to increase the scale factor |
|
.on('zoom', function() { |
|
|
|
if (yScaleOnChart.domain()[0] < 0) { |
|
var yVector = zoomListener.translate()[1] - yScaleOnChart(0) + yScaleOnChart.range()[0] |
|
zoomListener.translate([0, yVector]) |
|
|
|
} else if (yScaleOnChart.domain()[1] > 1) { |
|
var yVector = zoomListener.translate()[1] - yScaleOnChart(1) + yScaleOnChart.range()[1] |
|
zoomListener.translate([0, yVector]) |
|
} |
|
|
|
drawInnerChart({ barTransitionDuration: 50 }) |
|
|
|
links |
|
.transition() |
|
.duration(50) |
|
.attr('y2', function(d) { return yScaleOnChart(d.conversionRate) }) |
|
|
|
d3.select('g.viewport').call(viewport.extent(yScaleOnChart.domain())) // updates viewport |
|
|
|
}) |
|
|
|
wholeChartEnter |
|
.append('rect') |
|
.classed('innerchart zoom pane', true) |
|
.attr('transform', 'translate(' + (margin.left + spaceBetweenYAxisAndChart) + ',' + margin.top + ')') |
|
.attr('width', width) |
|
.attr('height', height) |
|
.call(zoomListener) |
|
|
|
// |
|
// LINK LAYER - links between y axis and chart journeys |
|
// |
|
|
|
wholeChartEnter |
|
.append('g') |
|
.classed('linklayer', true) |
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') |
|
|
|
var links = wholeChart.select('g.linklayer').selectAll('line') |
|
.data(function(d) { return d }, function(d) { return d.letters }) |
|
|
|
links |
|
.transition() |
|
.duration(1900) |
|
.attr('y1', function(d) { return yScaleOnAxis(d.conversionRate) }) |
|
.attr('y2', function(d) { return yScaleOnChart(d.conversionRate) }) |
|
|
|
links.enter() |
|
.append('line') |
|
.attr('x1', 1) |
|
.attr('y1', function(d) { return yScaleOnAxis(d.conversionRate) }) |
|
.attr('x2', 1) |
|
.attr('y2', function(d) { return yScaleOnChart(d.conversionRate) }) |
|
.transition() |
|
.delay(1900-500) |
|
.duration(400) |
|
.attr('x2', spaceBetweenYAxisAndChart - barTipLength + 1) |
|
|
|
links.exit() |
|
.transition() |
|
.delay(100) |
|
.duration(200) |
|
.attr('x1', spaceBetweenYAxisAndChart - barTipLength + 1) |
|
.remove() |
|
|
|
// |
|
// DENSITY PLOT - circles on y axis that convey conversionRate density |
|
// |
|
|
|
wholeChartEnter |
|
.append('g') |
|
.classed('density plot', true) |
|
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') |
|
|
|
var circleTicks = wholeChart.select('g.density.plot').selectAll('circle') |
|
.data(function(d) { return d }, function(d) { return d.letters }) |
|
|
|
circleTicks |
|
.transition() |
|
.duration(1900) |
|
.attr('cy', function(d) { return yScaleOnAxis(d.conversionRate) }) |
|
|
|
circleTicks.enter() |
|
.append('circle') |
|
.attr('cy', function(d) { return yScaleOnAxis(d.conversionRate) }) |
|
.attr('r', 0) |
|
.style('opacity', 0) |
|
.transition() |
|
.delay(1900-500-100) |
|
.duration(500) |
|
.attr('r', 5) |
|
.style('opacity', 0.05) |
|
|
|
circleTicks.exit() |
|
.transition() |
|
.duration(500) |
|
.attr('r', 0) |
|
.style('opacity', 0) |
|
.remove() |
|
|
|
// |
|
// THE CHART - in its own function so that it call be redrawn when needed (not only when new dataset passed in) |
|
// |
|
|
|
// bring viewport and chart back to full domain when new dataset passed in |
|
d3.select('g.viewport') |
|
.call(viewport.extent(yScaleOnAxis.domain())) |
|
|
|
yScaleOnChart.domain(viewport.extent()) |
|
|
|
drawInnerChart() // draw the inner chart when new dataset passed in |
|
|
|
function drawInnerChart(params) { |
|
// this function is called every time the chart must be redrawn on screen |
|
// i.e. when the viewport changes (zoom or scroll) and at new dataset |
|
|
|
var params = params || {} |
|
var barTransitionDuration = params.barTransitionDuration || 1900 |
|
|
|
var journeys = innerchart.selectAll('g.journey') |
|
.data(function(d) { return d }, function(d) { return d.letters }) // the second function, the 'key' function, determines that each journey is uniquely identified by its letter sequence |
|
|
|
journeys |
|
.transition() |
|
.duration(barTransitionDuration) |
|
.attr('transform', function(d) { return 'translate(' + 0 + ',' + yScaleOnChart(d.conversionRate) + ')' }) |
|
|
|
var journeysEnter = journeys.enter() |
|
.append('g') |
|
.classed('journey', true) |
|
.attr('transform', function(d) { return 'translate(' + 0 + ',' + (-200) + ')' }) |
|
|
|
journeysEnter |
|
.append('line') |
|
.attr('x1', -barTipLength + 1) |
|
.attr('x2', width + barTipLength - 1) |
|
|
|
journeysEnter |
|
.transition() |
|
.duration(barTransitionDuration) |
|
.attr('transform', function(d) { return 'translate(' + 0 + ',' + yScaleOnChart(d.conversionRate) + ')' }) |
|
|
|
journeys.exit() |
|
.transition() |
|
.delay(300) |
|
.duration(barTransitionDuration) |
|
.attr('transform', 'translate(' + 0 + ',' + (totalHeight + 200) + ')') |
|
.remove() |
|
|
|
var journeySteps = journeys.selectAll('rect') |
|
.data(function(d) { return d.letters }, function(d,i) { return d+i.toString() }) |
|
|
|
journeySteps.enter() |
|
.append('rect') |
|
.classed('journeyStep step', true) |
|
.style('fill', function(d) { return touchpointToColorScale(d) }) |
|
.style('stroke', function(d) { |
|
return d3.rgb(touchpointToColorScale(d)).darker() |
|
}) |
|
.append('title') |
|
.text(function(d) { return d }) |
|
|
|
journeySteps |
|
.transition() |
|
.delay(500) |
|
.attr('x', function(d,i) { return journeyStepXPos(i) }) |
|
.attr('y', function(d) { return -getBarHeightFromParent(this)/2 }) |
|
.attr('width', journeyStepXPos.rangeBand()) |
|
.attr('height', function(d) { return getBarHeightFromParent(this) }) |
|
|
|
} |
|
|
|
// |
|
// HELPER FUNCTIONS |
|
// |
|
|
|
function getBarHeightFromParent(node) { |
|
return countToBarHeightScale( |
|
d3.select(node.parentNode).datum().sessionCount |
|
) |
|
} |
|
|
|
}) |
|
} |
|
|
|
return drawChart |
|
|
|
} |
|
|