Skip to content

Instantly share code, notes, and snippets.

@jfsiii
Last active February 25, 2016 21:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jfsiii/6588330 to your computer and use it in GitHub Desktop.
Save jfsiii/6588330 to your computer and use it in GitHub Desktop.
Funnel
{"description":"Funnel","endpoint":"","display":"svg","public":true,"require":[],"fileconfigs":{"inlet.js":{"default":true,"vim":false,"emacs":false,"fontSize":12},"_.md":{"default":true,"vim":false,"emacs":false,"fontSize":12},"config.json":{"default":true,"vim":false,"emacs":false,"fontSize":12},"inlet.css":{"default":true,"vim":false,"emacs":false,"fontSize":12}},"fullscreen":false,"play":false,"loop":false,"restart":false,"autoinit":true,"pause":true,"loop_type":"pingpong","bv":false,"nclones":15,"clone_opacity":0.4,"duration":3000,"ease":"linear","dt":0.01,"thumbnail":"http://i.imgur.com/yFqd5zd.png","ajax-caching":true}
svg {
width: 100%;
height: 100%;
}
.funnel-background {
fill: white;
}
.funnel-label, .funnel-value,
.funnel-cohort-label, .funnel-cohort-value,
.derived-column-value, .derived-column-change {
text-anchor: middle;
font-weight: bold;
font-size: 12pt;
}
.funnel-label, .funnel-value,
.funnel-cohort-label, .funnel-cohort-value {
fill: white;
}
.funnel-value {
font-size: 18pt;
}
.funnel-cohort-label {
fill: #cccccc;
}
.funnel-part-active circle {
fill: #00ACEE;
}
.funnel-part-outcome .funnel-part-cohort {
display: none;
}
.funnel-part-active .funnel-part-cohort {
display: initial;
}
.funnel-part-cohort circle {
fill: #333333;
}
.derived-column-value, .derived-column-change {
fill: #555555;
}
.arrow {
stroke: #00ACEE;
stroke-width: 0.20em;
marker-end: url(#arrowhead)
}
#arrowhead {
fill: #00ACEE;
}
// Circle data set
var columnAData = {
nodes: [{
r: 75,
label: 'Column A',
value: '12,345',
color: '#00ACEE',
nodes: [
{
label: "Your Stuff",
value: '1,234',
r: 34,
// `gap: 0` will make circles touch
// `gap: 10` puts 10px space between them
gap: 17,
// an explicit distance, regardless of radius, between two circles
// overrides `gap` setting
// distance: 131,
color: '#3333333',
rotation: d3.functor(-243)
}
]
}]
};
var columnBData = {
nodes: [{
r: 150,
label: 'Column B',
value: '34,567,890',
color: '#00ACEE',
nodes: [
{
label: "Things from your stuff",
lines: ["Things from", "your stuff"],
value: '1,234',
r: 34,
// `gap: 0` will make circles touch
// `gap: 10` puts 10px space between them
gap: 17,
// an explicit distance, regardless of radius, between two circles
// overrides `gap` setting
// distance: 131,
color: '#00acee',
rotation: d3.functor(-243)
}
]
}]
};
var columnCData = {
isOutcome: true,
nodes: [{
r: 66,
label: 'Outcome 1',
value: '12,345',
color: '#ccc',
nodes: [
{
label: "Clicks",
value: '1,234',
r: 25,
// `gap: 0` will make circles touch
// `gap: 10` puts 10px space between them
gap: 17,
// an explicit distance, regardless of radius, between two circles
// overrides `gap` setting
// distance: 131,
color: '#00acee',
rotation: d3.functor(-243)
}
]
}, {
r: 66,
label: 'Outcome 2',
value: '10,234',
color: '#ccc',
isActive: false,
nodes: [
{
label: "Things from your stuff",
lines: ["Things from", "your stuff"],
value: '1,234',
r: 30,
// `gap: 0` will make circles touch
// `gap: 10` puts 10px space between them
gap: 20,
// an explicit distance, regardless of radius, between two circles
// overrides `gap` setting
// distance: 131,
color: '#00acee',
rotation: d3.functor(132)
}
]
}, {
r: 66,
label: 'Outcome 3',
value: '9,012',
color: '#ccc',
isActive: true,
nodes: [
{
label: "RTs",
lines: ["More", "by you"],
value: '1,234',
r: 25,
// `gap: 0` will make circles touch
// `gap: 10` puts 10px space between them
gap: 17,
// an explicit distance, regardless of radius, between two circles
// overrides `gap` setting
// distance: 131,
color: '#00acee',
rotation: d3.functor(-243)
}
]
}]
};
var funnelColumnsData = [columnAData, columnBData, columnCData];
var svg = d3.select('svg');
var svgWidth = parseInt(svg.style('width'), 10);
var svgHeight = parseInt(svg.style('height'), 10);
// Apply the background color
svg.append('rect')
.attr({
'class': 'funnel-background',
width: svgWidth,
height: svgHeight
})
// definitions (only the arrowhead, atm)
svg.append('defs').selectAll('marker')
.data(['arrowhead'])
.enter()
.append('marker')
.attr({
id: String,
viewBox: '0 -5 10 10',
refX: 8,
refY: 0,
markerWidth: 5,
markerHeight: 5,
orient: 'auto'
})
.append('path')
.attr('d', 'M0,-5L10,0L0,5');
// create the columns for each funnel part
var numCols = funnelColumnsData.length;
var colWidth = (svgWidth / numCols);
var colCenterX = colWidth / 2;
var colCenterY = svgHeight / 2;
// Generate the circles before the derived text
// so that the text will appear "above" them if they collide
var funnelColumns = svg.selectAll('g.column-funnel')
.data(funnelColumnsData, function (d, i) {
return d.nodes[0].label;
})
.enter()
.append('g')
.attr({
'class': 'column-funnel',
transform: funnelColumnTransform
})
// iterate through the columns
// using `each` instead of continuing with the enter() chain
// because determining the rotation for the outcomes circles
// requires the index of both column and group
// e.g. col[2] and outcome[1]
funnelColumns.each(createFunnelParts);
// generate the derived columns
createDerivedColumns();
//////////////////////////////////////////////
// lib functions
//////////////////////////////////////////////
function createFunnelParts(partData, partIndex) {
// create the column containers
var part = d3.select(this)
.selectAll('g.funnel-part')
.data(function (d, i) {
// we want the active outcome to appear "above" the others
// however, SVG doesn't have z-index so the last DOM node is "above" its siblings
// sort the nodes so that the active outcome is last
d.nodes.sort(activeChildrenLast);
d.nodes.forEach(generateRotationAccessors, d);
return d.nodes;
}, function (d, i) {
return d.label;
})
part
.enter()
.append('g')
.classed('funnel-part', true)
.classed('funnel-part-outcome', partData.isOutcome)
.classed('funnel-part-active', pluck('isActive'))
.attr('transform', function (d, i){
return d.transform(d, i);
});
part
.each(addFunnelCircle)
.each(addFunnelText)
.each(createCohortParts)
}
function createCohortParts(d, i) {
if (!d.nodes) {
return;
}
var part = d3.select(this)
.selectAll('g.funnel-part-cohort')
.data(function (d, i) {
d.nodes.forEach(generateRotationAccessors, d);
return d.nodes;
}, function (d, i) {
return d.label;
})
part
.enter()
.append('g')
.attr('class', 'funnel-part-cohort')
.attr('transform', function (d, i){
return d.transform(d, i);
});
part
.each(addFunnelCircle)
.each(addCohortText)
}
function addFunnelCircle() {
// append the circle
var circle = d3.select(this)
.append('circle')
.attr('r', pluck('r'))
.attr('fill', pluck('color'));
return circle;
}
function addFunnelText() {
var part = d3.select(this);
// Create the text for the labels
part
.append('text')
.attr({
'class': 'funnel-label'
})
.text(pluck('label'));
// Create the text for the values
part
.append('text')
.attr({
'class': 'funnel-value',
dy: '1em'
})
.text(pluck('value'))
return part
}
function addCohortText(d) {
var part = d3.select(this);
// Create the text for the values
part
.append('text')
.attr({
'class': 'funnel-cohort-value',
dy: '0.5em'
})
.text(pluck('value'))
// Create the text for the labels
var lines;
if (d.lines) {
lines = d.lines;
} else {
lines = [d.label]
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
var dy = i + 0.5 + 'em';
part
.append('text')
.attr({
'class': 'funnel-cohort-label',
dy: dy,
'transform': 'translate(' + (d.r * 2.5) + ')'
})
.text(line);
}
return part
}
function createDerivedColumns() {
var lineGenerator = d3.svg.line()
.x(pluck('x'))
.y(pluck('y'))
.interpolate('linear');
var derivedTextAttrs = {
length: derivedColumnWidth,
transform: function (d, i) {
var width = d3.select(this).attr('length');
return 'translate(' + width/2 + ',0)';
}
};
// create the derived column data
var derivedColumnData = d3.range(0, numCols - 1).map(function (val, i) {
// we're not storing any info for the derived columns, atm
// but let's maintain the "data" part of Data Driven Documents
return {
// trying to force the data to be recognized as `enter`ing
key: (Date.now() + Math.random()).toString().replace('.', '')
};
});
var derivedCol = svg.selectAll('g.column-derived')
.data(derivedColumnData, function (d, i) {
return d.key;
})
var derivedEnter = derivedCol
.enter()
.append('g')
.attr({
'class': 'column-derived',
'transform': derivedColumnTransform
})
// add the arrow
derivedEnter
.append('path')
.attr({
'class': 'arrow',
d: function (d, i) {
return lineGenerator(lineData(d, i));
}
})
// text for the label above the line
derivedEnter
.append('text')
.attr(derivedTextAttrs)
.attr({
'class': 'derived-column-value',
dy: '-1em'
})
.text('Stuff between')
// text for the values below the line
derivedEnter
.append('text')
.attr(derivedTextAttrs)
.attr({
'class': 'derived-column-change',
dy: '1.5em'
})
.text(function (d, i) {
return ['columns', i+1, 'and', i+2].join(' ')
})
}
function funnelColumnTransform(d, i) {
// translate in the column amount to reach the left column edge
var x = (colWidth * i);
// translate in some more to reach the center of the column
x += colCenterX;
var y = colCenterY;
return 'translate(' + x + ',' + y + ')';
}
function derivedColumnPosition(d, i) {
var x0 = (colWidth * i) + colCenterX + funnelColumnsData[i].nodes[0].r;
var x1 = (colWidth * (i + 1)) + colCenterX - funnelColumnsData[i + 1].nodes[0].r;
return {
x0: x0,
x1: x1
};
}
function derivedColumnWidth(d, i) {
var x = derivedColumnPosition(d, i);
return x.x1 - x.x0
}
function derivedColumnTransform(d, i) {
var x = derivedColumnPosition(d, i).x0;
var y = colCenterY;
return 'translate(' + x + ',' + y + ')';
}
function lineData(d, i) {
var coords = [
{
x: 0,
y: 0
}, {
x: derivedColumnWidth(d, i),
y: 0
}
];
return coords;
}
function activeChildrenLast(a, b) {
var aNum = +(a.isActive) || 0;
var bNum = +(b.isActive) || 0;
return aNum > bNum;
}
function pluck(key) {
return function keyAccessor(o) {
return o[key];
};
}
function deg2rad(deg) {
return deg * (Math.PI/180);
}
function translateDistanceAtAngle(r, angle) {
var x, y;
if (!angle || angle == 0) {
x = 0;
y = 0;
} else {
x = r * Math.cos(deg2rad(angle));
y = r * Math.sin(deg2rad(angle))
}
var transform = d3.transform('translate('+ x +','+ y +')');
return transform;
}
function generateRotationAccessors(node,nodeIndex,nodes){
// create a list of rotation angles for the circles
var slice = 360 / nodes.length
var rotations = d3.range(slice, 360, slice);
var rootNode = this;
if (!node.rotation) {
node.rotation = function rotation(d, i) {
return rotations[i];
};
}
if (rootNode.isOutcome) {
// siblings equally spaced around the same point
node.transform = function outcomeTransform(d, i) {
var distance = d.r;
// 0 is E (on a compass), we want it to be W
var angle = (d.rotation(d, i) || 0) - 180;
var transform = translateDistanceAtAngle(distance, angle);
// we don't want it centered on the column edge
if (d.isActive) {
transform.translate[0] += distance;
} else {
// move the inactive outcomes a bit more
transform.translate[0] += distance * 1.1;
}
return transform.toString();
}
} else {
// offset d.distance OR combined length of both radiuses
node.transform = function(d, i) {
var planetRadius = (rootNode.r || 0);
var satelliteRadius = d.r || 25;
var gap = d.gap || 0;
var distance = d.distance || (planetRadius + satelliteRadius + gap);
var angle = d.rotation(d, i);
var transform = translateDistanceAtAngle(distance, angle);
return transform.toString();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment