funnel-chart
Version:
Funnel chart visualization using HTML5 canvas
331 lines (263 loc) • 10.5 kB
JavaScript
/*!
* Funnel Chart v1.1.1
* https://github.com/promotably/funnel-chart
*
* Copyright 2015 Promotably LLC
* Released under the MIT license
*/
(function (root, factory) {
if(typeof define === 'function' && define.amd) {
define(factory);
}
else if(typeof module === 'object' && module.exports) {
module.exports = factory();
}
else {
root.FunnelChart = factory();
}
}(this, function() {
function extend(out) {
out = out || {};
for (var i = 1; i < arguments.length; i++) {
if (!arguments[i])
continue;
for (var key in arguments[i]) {
if (arguments[i].hasOwnProperty(key))
out[key] = arguments[i][key];
}
}
return out;
}
// canvas arg can be an ID string or HTMLElement
function FunnelChart(canvas, settings) {
// Allows FunnelChart to be instantiated without use of "new"
if (!(this instanceof FunnelChart))
return new FunnelChart(canvas, settings);
// Ensure canvas is an HTMLElement
this.canvas = (typeof canvas === 'string') ?
document.getElementById(canvas) : canvas;
// TODO: Check settings.values exists
if(!settings.values || !settings.values.length)
throw('A values setting must be provided');
// Extend default settings
this.settings = extend(this.settings, settings);
// Init!
this.initialize();
}
FunnelChart.prototype = extend(FunnelChart.prototype, {
settings: {
// Boolean - Whether to display % change between sections
displayPercentageChange: true,
// Number - The number of decimal places that should be displayed for %
// change values
pPrecision: 1,
// String - The color of the horizontal label lines (if labels are shown)
labelLineColor: '#eee',
// String or Array - The font color(s) of the labels.
labelFontColor: '#657274',
// String or Array - The color(s) of the funnel sections.
sectionColor: '#0498b3',
// String or Array - The color(s) of the funnel percentage sections.
pSectionColor: '#bfd1d4',
// String - The font for labels and values
font: 'Helvetica Neue',
// Number - The maximum font size in pixels (px) for labels and values.
// This will always be used where possible unless the height of the
// funnel sections is too small to permit it, in which case the font size
// will be automatically reduced to fit
maxFontSize: 13,
// String - The font weight for labels and values
fontWeight: '300',
// String or Array - The font color(s) for funnel sections
sectionFontColor: '#fff',
// String or Array - The font color(s) for % change sections
pSectionFontColor: '#657274',
// Number - The height of the % change sections compared to the main
// funnel sections. This is a percent value.
pSectionHeightPercent: 100,
// Number - The percentage of the full canvas width that should be
// reserved for display of labels (if provided). The funnel will expand
// to fit the remainder.
labelWidthPercent: 30,
// Number - The percentage width difference between the top and the
// bottom of the funnel.
funnelReductionPercent: 40,
// Number - The space between the right hand edge of the funnel and the
// label text in pixels.
labelOffset: 10,
// Number - The line height between each funnel section
lineHeight: 1
},
initialize: function() {
this.calculateDimensions();
this.draw();
},
calculateDimensions: function() {
var settings = this.settings,
labelWidth, sectionTotalHeight, multiplier;
// Width and height of canvas
this.width = this.canvas.offsetWidth;
this.height = this.canvas.offsetHeight;
// Ensure canvas dimensions are correct for device pixel ratio
this.createHiDPICanvas();
// Width allocated to labels
labelWidth = this.hasLabels() ? this.width * (settings.labelWidthPercent / 100) : 0;
this.labelMaxWidth = labelWidth - settings.labelOffset;
// Start and end width of funnel
this.startWidth = this.width - labelWidth;
this.endWidth = this.startWidth * (settings.funnelReductionPercent / 100);
// Total height of each section
sectionTotalHeight = (this.height / (settings.values.length));
// Section heights
if(settings.displayPercentageChange) {
sectionTotalHeight = (this.height / (settings.values.length));
multiplier = this.height / (this.height - (sectionTotalHeight / (100 + settings.pSectionHeightPercent)) * settings.pSectionHeightPercent);
this.sectionHeight = (multiplier * ((sectionTotalHeight / (100 + settings.pSectionHeightPercent)) * 100));
this.pSectionHeight = (multiplier * ((sectionTotalHeight / (100 + settings.pSectionHeightPercent)) * settings.pSectionHeightPercent));
}
else {
this.sectionHeight = sectionTotalHeight;
this.pSectionHeight = 0;
}
},
pixelRatio: function(){
var ctx = this.canvas.getContext('2d'),
dpr = window.devicePixelRatio || 1,
bsr = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1;
return dpr / bsr;
},
createHiDPICanvas: function() {
var canvas = this.canvas,
ratio = this.pixelRatio(),
w = this.width,
h = this.height;
canvas.width = w * ratio;
canvas.height = h * ratio;
canvas.style.width = w + 'px';
canvas.style.height = h + 'px';
canvas.getContext('2d').setTransform(ratio, 0, 0, ratio, 0, 0);
},
draw: function() {
var canvas = this.canvas,
settings = this.settings;
if (canvas.getContext) {
var ctx = canvas.getContext('2d'),
maxAvailFontSize = ((settings.displayPercentageChange) ? this.pSectionHeight : this.sectionHeight) - 2;
// Reduce font size if necessary
if(settings.maxFontSize >= maxAvailFontSize)
settings.maxFontSize = maxAvailFontSize;
// Configure font styling
ctx.font = settings.fontWeight + ' ' + settings.maxFontSize + 'px ' + settings.font;
// Draw labels if we have any
if(this.hasLabels()) this.drawLabels(ctx);
// Draw funnel clipping area with white background
this.drawClippingArea(ctx, settings);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.clip();
// Draw funnel sections
this.drawSections(ctx);
// Tidy up funnel outline
this.drawClippingArea(ctx, settings, true);
ctx.lineWidth = 2;
ctx.strokeStyle = '#fff';
ctx.stroke();
}
},
drawLabels: function(ctx) {
var settings = this.settings,
i, yPos;
ctx.strokeStyle = settings.labelLineColor;
ctx.lineWidth = settings.lineHeight;
for(i = 0; i < settings.values.length; i++) {
yPos = this.calculateYPos(i) - 1;
ctx.fillStyle = this.sequentialValue(settings.labelFontColor, i);
ctx.fillText(
settings.labels[i] || '',
this.startWidth + settings.labelOffset,
yPos + (this.sectionHeight / 2) + (settings.maxFontSize / 2) - 2,
this.labelMaxWidth
);
if(i > 0) {
ctx.beginPath();
ctx.moveTo(i, yPos);
ctx.lineTo(this.width, yPos);
ctx.stroke();
}
if(i < (settings.values.length - 1) && settings.displayPercentageChange){
ctx.beginPath();
ctx.moveTo(i, yPos + this.sectionHeight);
ctx.lineTo(this.width, yPos + this.sectionHeight);
ctx.stroke();
}
}
},
drawClippingArea: function(ctx, settings, curvesOnly) {
var inset = (this.startWidth - this.endWidth) / 2;
var height = (settings.values.length * this.sectionHeight) +
((settings.values.length - 1) * this.pSectionHeight) +
(settings.values.length + 1),
lineOrMove = curvesOnly ? 'moveTo' : 'lineTo';
ctx.beginPath();
ctx.moveTo(0,0);
ctx[lineOrMove](this.startWidth,0);
ctx.quadraticCurveTo(
(this.startWidth - inset),
(height / 3),
(this.startWidth - inset),
height
);
ctx[lineOrMove](inset,height);
ctx.quadraticCurveTo(inset, (height / 3), 0, 0);
},
drawSections: function(ctx) {
var settings = this.settings,
i, yPos;
ctx.textAlign = 'center';
for(i = 0; i < settings.values.length; i++) {
yPos = this.calculateYPos(i);
ctx.fillStyle = this.sequentialValue(settings.sectionColor, i);
ctx.fillRect(0, yPos, this.startWidth, this.sectionHeight - settings.lineHeight);
ctx.fillStyle = this.sequentialValue(settings.sectionFontColor, i);
ctx.fillText(
settings.values[i],
this.startWidth / 2,
yPos + ((this.sectionHeight - settings.lineHeight) / 2) + (settings.maxFontSize / 2) - 2
);
if(i < (settings.values.length - 1) && settings.displayPercentageChange) {
ctx.fillStyle = this.sequentialValue(settings.pSectionColor, i);
ctx.fillRect(
0,
(yPos + this.sectionHeight),
this.startWidth,
this.pSectionHeight - settings.lineHeight
);
ctx.fillStyle = this.sequentialValue(settings.pSectionFontColor, i);
ctx.fillText(
(settings.values[i] === 0) ? '' : ((settings.values[i + 1] / settings.values[i]) * 100).toFixed(settings.pPrecision) + '%',
this.startWidth / 2,
yPos + this.sectionHeight + ((this.pSectionHeight - settings.lineHeight) / 2) + (settings.maxFontSize / 2) - 1
);
}
}
},
hasLabels: function() {
var labels = this.settings.labels;
return labels && !!labels.length;
},
calculateYPos: function(i) {
var sectionHeight = this.sectionHeight;
if(this.settings.displayPercentageChange) sectionHeight += this.pSectionHeight;
return sectionHeight * i;
},
sequentialValue: function(arr, i) {
if(typeof arr === 'string') return arr;
return arr[i % arr.length];
}
});
return FunnelChart;
}));