sense-funnel-chart
Version:
Funnel Chart for Qlik Sense.
693 lines (601 loc) • 17.1 kB
JavaScript
(function(global) {
/* global d3 */
/* jshint bitwise: false */
"use strict";
/**
* D3Funnel
*
* @param {string} selector A selector for the container element.
*
* @return {void}
*/
var D3Funnel = function(selector)
{
this.selector = selector;
// Default configuration values
this.defaults = {
width: 350,
height: 400,
bottomWidth: 1/3,
bottomPinch: 0,
isCurved: false,
curveHeight: 20,
fillType: "solid",
isInverted: false,
hoverEffects: false,
dynamicArea: false,
minHeight: false,
animation: false,
label: {
fontSize: "14px",
fill: "#fff"
}
};
};
/**
* Draw the chart inside the container with the data and configuration
* specified. This will remove any previous SVG elements in the container
* and draw a new funnel chart on top of it.
*
* @param {array} data A list of rows containing a category, a count,
* and optionally a color (in hex).
* @param {Object} options An optional configuration object to override
* defaults. See the docs.
*
* @return {void}
*/
D3Funnel.prototype.draw = function(data, options)
{
// Initialize chart options
this.__initialize(data, options);
// Remove any previous drawings
d3.select(this.selector).selectAll("svg").remove();
// Add the SVG
this.svg = d3.select(this.selector)
.append("svg")
.attr("width", this.width)
.attr("height", this.height);
this.sectionPaths = this.__makePaths();
// Define color gradients
if (this.fillType === "gradient") {
this.__defineColorGradients(this.svg);
}
// Add top oval if curved
if (this.isCurved) {
this.__drawTopOval(this.svg, this.sectionPaths);
}
// Add each block section
this.__drawSection(0);
};
/**
* Initialize and calculate important variables for drawing the chart.
*
* @param {array} data
* @param {Object} options
*
* @return {void}
*/
D3Funnel.prototype.__initialize = function(data, options)
{
if (!this.__isArray(data) || data.length === 0 ||
!this.__isArray(data[0]) || data[0].length < 2) {
throw {
name: "D3 Funnel Data Error",
message: "Funnel data is not valid."
};
}
// Initialize options if not set
options = typeof options !== "undefined" ? options : {};
this.data = data;
// Counter
var i = 0;
// Prepare the configuration settings based on the defaults
// Set the default width and height based on the container
var settings = this.__extend({}, this.defaults);
settings.width = parseInt(d3.select(this.selector).style("width"), 10);
settings.height = parseInt(d3.select(this.selector).style("height"), 10);
// Overwrite default settings with user options
var keys = Object.keys(options);
for (i = 0; i < keys.length; i++) {
if (keys[i] !== "label") {
settings[keys[i]] = options[keys[i]];
}
}
// Label settings
if ("label" in options) {
var validLabelOptions = /fontSize|fill/;
var labelOption;
for (labelOption in options.label) {
if (labelOption.match(validLabelOptions)) {
settings.label[labelOption] = options.label[labelOption];
}
}
}
this.label = settings.label;
// In the case that the width or height is not valid, set
// the width/height as its default hard-coded value
if (settings.width <= 0) {
settings.width = this.defaults.width;
}
if (settings.height <= 0) {
settings.height = this.defaults.height;
}
// Initialize the colors for each block section
var colorScale = d3.scale.category10();
for (i = 0; i < this.data.length; i++) {
var hexExpression = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
// If a color is not set for the record, add one
if (!("2" in this.data[i]) || !hexExpression.test(this.data[i][2])) {
this.data[i][2] = colorScale(i);
}
}
// Initialize funnel chart settings
this.width = settings.width;
this.height = settings.height;
this.bottomWidth = settings.width * settings.bottomWidth;
this.bottomPinch = settings.bottomPinch;
this.isCurved = settings.isCurved;
this.curveHeight = settings.curveHeight;
this.fillType = settings.fillType;
this.isInverted = settings.isInverted;
this.hoverEffects = settings.hoverEffects;
this.dynamicArea = settings.dynamicArea;
this.minHeight = settings.minHeight;
this.animation = settings.animation;
// Calculate the bottom left x position
this.bottomLeftX = (this.width - this.bottomWidth) / 2;
// Change in x direction
// Will be sharper if there is a pinch
this.dx = this.bottomPinch > 0 ?
this.bottomLeftX / (data.length - this.bottomPinch) :
this.bottomLeftX / data.length;
// Change in y direction
// Curved chart needs reserved pixels to account for curvature
this.dy = this.isCurved ?
(this.height - this.curveHeight) / data.length :
this.height / data.length;
// Support for events
this.onItemClick = settings.onItemClick;
};
/**
* Create the paths to be used to define the discrete funnel sections and
* returns the results in an array.
*
* @return {array}
*/
D3Funnel.prototype.__makePaths = function()
{
var paths = [];
// Initialize velocity
var dx = this.dx;
var dy = this.dy;
// Initialize starting positions
var prevLeftX = 0;
var prevRightX = this.width;
var prevHeight = 0;
// Start from the bottom for inverted
if (this.isInverted) {
prevLeftX = this.bottomLeftX;
prevRightX = this.width - this.bottomLeftX;
}
// Initialize next positions
var nextLeftX = 0;
var nextRightX = 0;
var nextHeight = 0;
var middle = this.width / 2;
// Move down if there is an initial curve
if (this.isCurved) {
prevHeight = 10;
}
var topBase = this.width;
var bottomBase = 0;
var totalArea = this.height * (this.width + this.bottomWidth) / 2;
var slope = 2 * this.height / (this.width - this.bottomWidth);
// This is greedy in that the section will have a guranteed height and
// the remaining is shared among the ratio, instead of being shared
// according to the remaining minus the guranteed
if (this.minHeight !== false) {
var height = (this.height - this.minHeight * this.data.length);
totalArea = height * (this.width + this.bottomWidth) / 2;
}
var totalCount = 0;
var count = 0;
// Harvest total count
for (var i = 0; i < this.data.length; i++) {
totalCount += this.__isArray(this.data[i][1]) ? this.data[i][1][0] : this.data[i][1];
}
// Create the path definition for each funnel section
// Remember to loop back to the beginning point for a closed path
for (i = 0; i < this.data.length; i++) {
count = this.__isArray(this.data[i][1]) ? this.data[i][1][0] : this.data[i][1];
// Calculate dynamic shapes based on area
if (this.dynamicArea) {
var ratio = count / totalCount;
var area = ratio * totalArea;
if (this.minHeight !== false) {
area += this.minHeight * (this.width + this.bottomWidth) / 2;
}
bottomBase = Math.sqrt((slope * topBase * topBase - (4 * area)) / slope);
dx = (topBase / 2) - (bottomBase / 2);
dy = (area * 2) / (topBase + bottomBase);
if (this.isCurved) {
dy = dy - (this.curveHeight/this.data.length);
}
topBase = bottomBase;
}
// Stop velocity for pinched sections
if (this.bottomPinch > 0) {
// Check if we've reached the bottom of the pinch
// If so, stop changing on x
if (!this.isInverted) {
if (i >= this.data.length - this.bottomPinch) {
dx = 0;
}
}
// Pinch at the first sections relating to the bottom pinch
// Revert back to normal velocity after pinch
else {
// Revert velocity back to the intial if we are using
// static area's (prevents zero velocity if isInverted
// and bottomPinch are non trivial and dynamicArea is false)
if (!this.dynamicArea) {
dx = this.dx;
}
dx = i < this.bottomPinch ? 0 : dx;
}
}
// Calculate the position of next section
nextLeftX = prevLeftX + dx;
nextRightX = prevRightX - dx;
nextHeight = prevHeight + dy;
// Expand outward if inverted
if (this.isInverted) {
nextLeftX = prevLeftX - dx;
nextRightX = prevRightX + dx;
}
// Plot curved lines
if (this.isCurved) {
paths.push([
// Top Bezier curve
[prevLeftX, prevHeight, "M"],
[middle, prevHeight + (this.curveHeight - 10), "Q"],
[prevRightX, prevHeight, ""],
// Right line
[nextRightX, nextHeight, "L"],
// Bottom Bezier curve
[nextRightX, nextHeight, "M"],
[middle, nextHeight + this.curveHeight, "Q"],
[nextLeftX, nextHeight, ""],
// Left line
[prevLeftX, prevHeight, "L"]
]);
}
// Plot straight lines
else {
paths.push([
// Start position
[prevLeftX, prevHeight, "M"],
// Move to right
[prevRightX, prevHeight, "L"],
// Move down
[nextRightX, nextHeight, "L"],
// Move to left
[nextLeftX, nextHeight, "L"],
// Wrap back to top
[prevLeftX, prevHeight, "L"],
]);
}
// Set the next section's previous position
prevLeftX = nextLeftX;
prevRightX = nextRightX;
prevHeight = nextHeight;
}
return paths;
};
/**
* Define the linear color gradients.
*
* @param {Object} svg
*
* @return {void}
*/
D3Funnel.prototype.__defineColorGradients = function(svg)
{
var defs = svg.append("defs");
// Create a gradient for each section
for (var i = 0; i < this.data.length; i++) {
var color = this.data[i][2];
var shade = shadeColor(color, -0.25);
// Create linear gradient
var gradient = defs.append("linearGradient")
.attr({
id: "gradient-" + i
});
// Define the gradient stops
var stops = [
[0, shade],
[40, color],
[60, color],
[100, shade]
];
// Add the gradient stops
for (var j = 0; j < stops.length; j++) {
var stop = stops[j];
gradient.append("stop").attr({
offset: stop[0] + "%",
style: "stop-color:" + stop[1]
});
}
}
};
/**
* Draw the top oval of a curved funnel.
*
* @param {Object} svg
* @param {array} sectionPaths
*
* @return {void}
*/
D3Funnel.prototype.__drawTopOval = function(svg, sectionPaths)
{
var leftX = 0;
var rightX = this.width;
var centerX = this.width / 2;
if (this.isInverted) {
leftX = this.bottomLeftX;
rightX = this.width - this.bottomLeftX;
}
// Create path form top-most section
var paths = sectionPaths[0];
var path = "M" + leftX + "," + paths[0][1] +
" Q" + centerX + "," + (paths[1][1] + this.curveHeight - 10) +
" " + rightX + "," + paths[2][1] +
" M" + rightX + ",10" +
" Q" + centerX + ",0" +
" " + leftX + ",10";
// Draw top oval
svg.append("path")
.attr("fill", shadeColor(this.data[0][2], -0.4))
.attr("d", path);
};
/**
* Draw the next section in the iteration.
*
* @param {int} index
*
* @return {void}
*/
D3Funnel.prototype.__drawSection = function(index)
{
if (index === this.data.length) {
return;
}
// Create a group just for this block
var group = this.svg.append("g");
// Fetch path element
var path = this.__getSectionPath(group, index);
path.data(this.__getSectionData(index));
// Add animation components
if (this.animation !== false) {
var self = this;
path.transition()
.duration(this.animation)
.ease("linear")
.attr("fill", this.__getColor(index))
.attr("d", this.__getPathDefinition(index))
.each("end", function() {
self.__drawSection(index + 1);
});
} else {
path.attr("fill", this.__getColor(index))
.attr("d", this.__getPathDefinition(index));
this.__drawSection(index + 1);
}
// Add the hover events
if (this.hoverEffects) {
path.on("mouseover", this.__onMouseOver)
.on("mouseout", this.__onMouseOut);
}
// ItemClick event
if (this.onItemClick) {
path.on( "click", this.onItemClick );
}
this.__addSectionLabel(group, index);
};
/**
* @param {Object} group
* @param {int} index
*
* @return {Object}
*/
D3Funnel.prototype.__getSectionPath = function(group, index)
{
var path = group.append("path");
if (this.animation !== false) {
this.__addBeforeTransition(path, index);
}
return path;
};
/**
* Set the attributes of a path element before its animation.
*
* @param {Object} path
* @param {int} index
*
* @return {void}
*/
D3Funnel.prototype.__addBeforeTransition = function(path, index)
{
var paths = this.sectionPaths[index];
var beforePath = "";
var beforeFill = "";
// Construct the top of the trapezoid and leave the other elements
// hovering around to expand downward on animation
if (!this.isCurved) {
beforePath = "M" + paths[0][0] + "," + paths[0][1] +
" L" + paths[1][0] + "," + paths[1][1] +
" L" + paths[1][0] + "," + paths[1][1] +
" L" + paths[0][0] + "," + paths[0][1];
} else {
beforePath = "M" + paths[0][0] + "," + paths[0][1] +
" Q" + paths[1][0] + "," + paths[1][1] +
" " + paths[2][0] + "," + paths[2][1] +
" L" + paths[2][0] + "," + paths[2][1] +
" M" + paths[2][0] + "," + paths[2][1] +
" Q" + paths[1][0] + "," + paths[1][1] +
" " + paths[0][0] + "," + paths[0][1];
}
// Use previous fill color, if available
if (this.fillType === "solid") {
beforeFill = index > 0 ? this.__getColor(index - 1) : this.__getColor(index);
// Use current background if gradient (gradients do not transition)
} else {
beforeFill = this.__getColor(index);
}
path.attr("d", beforePath)
.attr("fill", beforeFill);
};
/**
* @param {int} index
*
* @return {array}
*/
D3Funnel.prototype.__getSectionData = function(index)
{
return [{
index: index,
label: this.data[index][0],
value: this.__isArray(this.data[index][1]) ? this.data[index][1][0] : this.data[index][1], formattedValue: this.__isArray(this.data[index][1]) ? this.data[index][1][1] : this.data[index][1].toLocaleString(),
baseColor: this.data[index][2],
fill: this.__getColor(index)
}];
};
/**
* Return the color for the given index.
*
* @param {int} index
*
* @return {void}
*/
D3Funnel.prototype.__getColor = function(index)
{
if (this.fillType === "solid") {
return this.data[index][2];
} else {
return "url(#gradient-" + index + ")";
}
};
/**
* @param {int} index
*
* @return {string}
*/
D3Funnel.prototype.__getPathDefinition = function(index)
{
var pathStr = "";
var point = [];
var paths = this.sectionPaths[index];
for (var j = 0; j < paths.length; j++) {
point = paths[j];
pathStr += point[2] + point[0] + "," + point[1] + " ";
}
return pathStr;
};
/**
* @param {Object} data
*
* @return {void}
*/
D3Funnel.prototype.__onMouseOver = function(data)
{
d3.select(this).attr("fill", shadeColor(data.baseColor, -0.2));
};
/**
* @param {Object} data
*
* @return {void}
*/
D3Funnel.prototype.__onMouseOut = function(data)
{
d3.select(this).attr("fill", data.fill);
};
/**
* @param {Object} group
* @param {int} index
*
* @return {void}
*/
D3Funnel.prototype.__addSectionLabel = function(group, index)
{
var i = index;
var paths = this.sectionPaths[index];
var sectionData = this.__getSectionData(index)[0];
var textStr = sectionData.label + ": " + sectionData.formattedValue;
var textFill = this.data[i][3] || this.label.fill;
var textX = this.width / 2; // Center the text
var textY = !this.isCurved ? // Average height of bases
(paths[1][1] + paths[2][1]) / 2 :
(paths[2][1] + paths[3][1]) / 2 + (this.curveHeight / this.data.length);
group.append("text")
.text(textStr)
.attr({
"x": textX,
"y": textY,
"text-anchor": "middle",
"dominant-baseline": "middle",
"fill": textFill,
"pointer-events": "none"
})
.style("font-size", this.label.fontSize);
};
/**
* Check if the supplied value is an array.
*
* @param {mixed} value
*
* @return {bool}
*/
D3Funnel.prototype.__isArray = function(value)
{
return Object.prototype.toString.call(value) === "[object Array]";
};
/**
* Extends an object with the members of another.
*
* @param {Object} a The object to be extended.
* @param {Object} b The object to clone from.
*
* @return {Object}
*/
D3Funnel.prototype.__extend = function(a, b)
{
var prop;
for (prop in b) {
if (b.hasOwnProperty(prop)) {
a[prop] = b[prop];
}
}
return a;
};
/**
* Shade a color to the given percentage.
*
* @param {string} color A hex color.
* @param {float} shade The shade adjustment. Can be positive or negative.
*
* @return {void}
*/
function shadeColor(color, shade)
{
var f = parseInt(color.slice(1), 16);
var t = shade < 0 ? 0 : 255;
var p = shade < 0 ? shade * -1 : shade;
var R = f >> 16, G = f >> 8 & 0x00FF;
var B = f & 0x0000FF;
var converted = (0x1000000 + (Math.round((t - R) * p) + R) *
0x10000 + (Math.round((t - G) * p) + G) *
0x100 + (Math.round((t - B) * p) + B));
return "#" + converted.toString(16).slice(1);
}
global.D3Funnel = D3Funnel;
})(window);