Skip to content

Instantly share code, notes, and snippets.

@evandana
Created September 12, 2014 13:38
Show Gist options
  • Save evandana/965bd1a6e49693db793d to your computer and use it in GitHub Desktop.
Save evandana/965bd1a6e49693db793d to your computer and use it in GitHub Desktop.
D3 Grid of Bar Charts: Sortable (one bar per cell)
// TODO: add description of this library
// TODO: add README.md
// TODO: don't allow any hovering until the entire chart is drawn and done animating
var D3Chart = function (options) {
// options not yet needed
// ----------------------------------------------------
// get data
// ----------------------------------------------------
var getData = function (options) {
// hook up options to local vars and set defaults
var data = options.data || {},
columnInfo = options.columnInfo || {},
chartParams = options.chartParams || {},
metaDataParams = options.metaDataParams || {};
var preprocessData = function(data) {
// if the actual data is contained in a data object, then just get the actual data
// currently anything other than the data is ignored
// accepts "data = {data: data, ... }"" and "data = data"
if (! $.isArray(data)) {
data = data.data;
}
// convert data to numbers
$.each(data, function(i, row) {
$.each(columnInfo, function(j, val) {
// cast value as number
var val = +row[columnInfo[j].field];
data[i][columnInfo[j].field] = val;
});
});
return data;
};
var metaData = function(options) {
// hook up options to local vars and set defaults
var data = options.data || {};
var rowKeys = [],
colKeys = [],
sortOrder = 0; // asc|dsc
var getRowKeys = function(data) {
// if you already have a unique identifier, just return an array of these values in that order
// keys should adhere to the rules of CSS classes
// if both are null, then create
// TODO: allow a parameter to be passed in to choose one column as the unique key
// TODO: warn if a column does not have unique values
// TODO: check if "key" is used as a property of the object, if so use a different property (e.g. "uniqueKey")
if (rowKeys.length === 0 && !!data) {
$.each(data, function(i) {
var key = Math.floor(Math.random() * 1000000000000);
data[i].key = key; // TODO: unexpected mutation is not ideal
rowKeys.push(key);
});
}
return rowKeys;
};
var getColKeys = function(data) {
// make col keys from metaData columnInfo[i].key
colKeys = $.map(columnInfo, function(val) {
return val.field;
});
return colKeys;
};
var setRowKeys = function(newRowKeysArray) {
rowKeys = newRowKeysArray;
};
// in theory there could be more options, but i can't think of what they would be
var sortOrderOptions = [-1, 1]; // -1:ascending, 1:descending
var getSortOrder = function () {
return sortOrder;
}
var incrementSortOrder = function (sortType) {
// the returned value is used in a comparison function during a sort (b - a)
// negative value switches (b - a) to (a - b)
switch (sortType) { // 0:off, 1:asc only, 2:dsc only, 3:asc|dcs toggle
case 0:
sortOrder = 0;
break;
case 1:
sortOrder = sortOrderOptions[0]; // -1
break;
case 2:
sortOrder = sortOrderOptions[1]; // 1
break;
case 3:
// uses modulus to wrap around the sortOrderArray index
sortOrder = sortOrderOptions[(sortOrderOptions.indexOf(sortOrder) + 1) % (sortOrderOptions.length)];
break;
}
return sortOrder;
}
return {
// make private methods and vars publically available
columnInfo: columnInfo,
rowKeys: getRowKeys(data), // calculated on chart draw, not necessary to be calculated again... right?
colKeys: getColKeys(data), // calculated on chart draw, not necessary to be calculated again... right?
getSortOrder: getSortOrder,
incrementSortOrder: incrementSortOrder,
setRowKeys: setRowKeys
};
};
// preprocess data
data = preprocessData(data);
// create metaData from preprocessed data
var metaData = metaData({data: data, chartParams: chartParams});
return {data: data, metaData: metaData};
};
// ----------------------------------------------------
// merge input params with defaults (defaults get overridden if conflict exists)
// ----------------------------------------------------
var mergeStaticParams = function (options) {
// hook up options to local vars and set defaults
var params = options.params || {};
// define defaults, to be over ridden by incoming params
// TODO: not all of these properties are being used
// TODO: how many of these can be set with CSS instead and removed from here?
var defaultParams = {
sort: {
sortType: 3, // 0:off, 1:asc only, 2:dsc only, 3:asc|dcs toggle
animate: true,
duration: 300, // milliseconds
},
header: {
height: 20
},
footer: {
height: 30,
marginY: .2,
paddingY: .2
},
margin: {
left: 0,
top: 0,
right: 0,
bottom: 0
},
cell: {
paddingY: 1, // TODO: causes error for zeroLine when this is 0 // if this is too big it causes problems
paddingX: 1, // TODO: causes error for zeroLine when this is 0 // if this is too big it causes problems
marginY: .5, // if this is too big it causes problems
marginX: .5, // if this is too big it causes problems
color: 'url(#gradient)',
hover: {
color: '#cccccc'
},
stroke: '#bbbbbb',
strokeWidth: 1,
minWidth: .5
},
dataBar: {
width: 1, // width set dynamically
color: {
pos: '#4682B4',
neg: '#FFA500',
},
stroke: '#ccf',
hover: {
pos: '#B1DCFE',
neg: '#FED280'
},
minWidth: 1,
strokeWidth: 1,
animate: true,
duration: 500
},
zeroLine: {
strokeWidth: .5,
strokeColor: "black",
delay: 300, // entrance; should roughly match dataBar duration
duration: 300 // entrance
},
marginLine: {
strokeWidth: .2,
strokeColor: "gray",
delay: 300, // entrance; should roughly match dataBar duration
duration: 300 // entrance
},
color: {
fill: '#eeeeee'
}
};
// merge params into defaultParams (defaults get overridden if conflicts arise)
params = $.extend(defaultParams, params);
return params;
};
// ----------------------------------------------------
// create and merge dynamic params with defaults (defaults get overridden if conflict exists)
// ----------------------------------------------------
var mergeDynamicParams = function (options) {
// hook up options to local vars and set defaults
var params = options.params || {},
el = options.el || '',
data = options.data || {},
metaData = options.metaData || {};
// immediately append "el" so it can be used as "params.*" consistently everywhere
params.el = {
width: $(el).width(),
height: $(el).height()
};
// TODO: use d3.scale().ordinal() instead of this...
// dataScale for yAxis
var yScale = d3.scale.ordinal()
.domain(metaData.rowKeys)
.rangeRoundBands([0, params.el.height - params.header.height - params.footer.height], 0, 0);
// .rangeRoundBands([0, params.cell.height], .2);
// calculate dynamic params
var dynamicParams = {
chart: { // entire drawing
height: params.el.height,
width: params.el.width
},
grid: { // the grid (no header)
height: params.el.height - params.header.height - params.footer.height,
width: params.el.width
},
row: {
height: yScale.rangeBand()
},
cell: {
width: (function () {
// adding Math.max sets the minimum size for each bar (even though it will not be to scale)
// TODO: abstract first param in Math.max to a common param
// TODO: make .5 a parameter, this makes the minimum val visible (in case it is otherwise 0)
return Math.max( params.cell.minWidth, (params.el.width / metaData.colKeys.length) - params.cell.paddingX * 2 );
}()),
x: params.cell.marginX, // relative to cell start
y: params.cell.marginY, // relative to row
height: yScale.rangeBand() - params.cell.marginY * 2
},
dataBar: {
height: yScale.rangeBand() - (params.cell.paddingY * 2) - (params.cell.marginY * 2),
y: params.cell.marginY + params.cell.paddingY// relative to cell start
//params.cell.paddingY/2 + params.dataBar.strokeWidth + (params.cell.height - params.dataBar.height) / 2
}
};
// merge dynamicParams into params (params get overridden by dynamicParams if conflicts arise)
params.chart = $.extend(params.chart, dynamicParams.chart);
params.grid = $.extend(params.grid, dynamicParams.grid);
params.row = $.extend(params.row, dynamicParams.row);
params.cell = $.extend(params.cell, dynamicParams.cell);
params.dataBar = $.extend(params.dataBar, dynamicParams.dataBar);
return params;
}
// ----------------------------------------------------
// draw chart
// ----------------------------------------------------
var drawChart = function (options) {
// hook up options to local vars and set defaults
var data = options.data || {},
metaData = options.metaData || {},
el = options.el || '',
params = options.params || {};
// ----------------------------------------------------
// set up vars
// ----------------------------------------------------
// used for adjusting the domain/range of data for each column
// TODO: move this to metaData
// TODO: does D3 have a method for doing this more simply?
var getMinMaxByProperty = function (data, metaData) {
var minMax = {};
$.each(metaData.colKeys, function (i, colKey) {
minMax[colKey] = {
min: Math.min(0, d3.min(data, function(d) { return d[colKey]; }) ),
max: d3.max(data, function(d) { return d[colKey]; })
};
});
return minMax;
};
var minMaxByProperty = getMinMaxByProperty(data, metaData);
// creates a dataScale function for each column
// dataScale function returns the scaled value based on the given input
// this complexity is only used for xAxis
// TODO: move this to metaData
var getDataScales = function (data, metaData) {
var scaleByProperty = {};
$.each(metaData.colKeys, function (i, colKey) {
scaleByProperty[colKey] = d3.scale.linear(colKey)
.domain(
[
Math.min( 0, d3.min(data.map(function(d) { return d[colKey]; })) ), // draws the 0 mark even if chart has only values above 0
d3.max(data.map(function(d) { return d[colKey]; }))
]
)
// the first array element and the value subtracted from the second are what determine the padding on the outside of each column
// TODO: abstract this "5" and put it up in the properties
.range([15, params.cell.width - 15 ]);
});
return scaleByProperty;
};
var scaleByProperty = getDataScales(data, metaData);
// ----------------------------------------------------
// Create SVG
// ----------------------------------------------------
var svg = d3.select(el).append('svg')
.attr('width', params.chart.width)
.attr('height', params.chart.height)
// TODO: why do we have this and is it right?
.attr('preserveAspectRatio', 'xMidYMid');
// ----------------------------------------------------
// Define gradients
// ----------------------------------------------------
// define gradients
// TODO: name this more generically
// TODO: put these values in params
var gradient = svg.append("svg:defs")
.append("svg:linearGradient")
.attr("id", "gradient")
.attr("x1", "0%")
.attr("x2", "100%")
// .attr("y1", "100%")
// .attr("y2", "100%")
.attr("spreadMethod", "pad");
gradient.append("svg:stop")
.attr("offset", "0%")
.attr("stop-color", "#ddd")
.attr("stop-opacity", 1);
gradient.append("svg:stop")
.attr("offset", "100%")
.attr("stop-color", "#eee")
.attr("stop-opacity", 1);
var gradient2 = svg.append("svg:defs")
.append("svg:linearGradient")
.attr("id", "gradient")
.attr("x1", "0%")
.attr("x2", "100%")
// .attr("y1", "100%")
// .attr("y2", "100%")
.attr("spreadMethod", "pad");
gradient2.append("svg:stop")
.attr("offset", "0%")
.attr("stop-color", "#ccc")
.attr("stop-opacity", 1);
gradient2.append("svg:stop")
.attr("offset", "100%")
.attr("stop-color", "#ddd")
.attr("stop-opacity", 1);
// ----------------------------------------------------
// Render the chart
// ----------------------------------------------------
var grid = svg
// Wrapper group that will be translated by left and top margin
.append('g')
.attr('class', 'grid')
.attr('transform', 'translate(' + params.margin.left + ', ' + params.margin.top + ')')
// a group for every row
.selectAll('g')
.data(data)
.enter();
var rows = grid
.append('g')
.attr('class', function(d) {
return 'row row-' + d.key;
})
.attr('transform', function(d) {
var yvalue = metaData.rowKeys.indexOf(d.key);
var y = yvalue * (params.row.height);
return 'translate(0, ' + y + ')';
})
// a group for every column
.selectAll('g')
.data(metaData.columnInfo)
.enter()
.append('g')
.attr('class', function(d) {
return 'cell col-' + d.field;
})
.attr('transform', function(d) {
var xvalue = metaData.colKeys.indexOf(d.field);
var x = xvalue * (params.cell.width + params.cell.x * 2);
return 'translate(' + x + ' 0)';
});
// draw cell backgrounds
var cellBackgrounds = rows
.append('rect')
.attr('class', 'cellBackground')
.attr('x', params.cell.x)
.attr('y', params.cell.y)
.attr('width', params.cell.width)
.attr('height', params.cell.height)
.style('fill', params.cell.color);
// Draw dataBars
rows
.data(function (d) {
return $.map(metaData.colKeys, function (colKey) {
return {
value: d[colKey],
field: colKey
};
});
})
.append('rect')
.attr('class', function(d) {
return d.value >= 0 ? 'dataBar pos' : 'dataBar neg'
})
.attr('x', function (d) {
var xvalue = metaData.colKeys.indexOf(d.field),
x;
// if your min value per range is below zero or above zero, calculate the "0 line" differently
if (minMaxByProperty[d.field].min >= 0) {
x = Math.abs(scaleByProperty[d.field](Math.max(0, minMaxByProperty[d.field].min)));
} else {
x = Math.abs(scaleByProperty[d.field](Math.min(0, d.value)));
}
return x;
})
.attr('y', function () {
return params.dataBar.y;
})
.attr('height', params.dataBar.height)
.attr('fill', function(d) {
return d.value < 0 ? params.dataBar.color.neg : params.dataBar.color.pos;
})
.transition()
.duration(function () {
if (params.dataBar.animate) {
return params.dataBar.duration;
} else {
return 0;
};
})
.attr('width', function(d) {
var width = Math.abs(scaleByProperty[d.field](d.value) - scaleByProperty[d.field](Math.max(0, minMaxByProperty[d.field].min)));
// adding Math.max sets the minimum size for each bar (even though it will not be to scale)
// TODO: abstract first param in Math.max to a common param
return Math.max( params.dataBar.minWidth, width);
});
// ----------------------------------------------------
// Render the headers and text
// ----------------------------------------------------
// add headers
var headerRow = svg
.append('g')
.attr('class', 'gridHeader')
// a group for every column
.selectAll('g')
.data(metaData.columnInfo)
.enter();
var headerGroups = headerRow
.append('g')
.attr('width', params.cell.width)
.attr('height', params.grid.height)
.attr('class', function(d) {
return 'cell col-' + d.field;
})
.attr('transform', function(d) {
var xvalue = metaData.colKeys.indexOf(d.field);
var x = xvalue * (params.cell.width + params.cell.paddingX);
return 'translate(' + x + ' 0)';
});
var headerText = headerGroups
.append('text')
// TODO: do this more cleanly and for better presentation
.attr('class', function(d) {
return 'headerText headerCol-' + d.field;
})
.attr('data', function(d) { return d.field; })
.attr('dy', params.cell.height/2 - 2)
.attr('dx', '2em')
.text(function(d) {
return d.title;
});
// ----------------------------------------------------
// Render the footers and ticks
// ----------------------------------------------------
// add footers
var footerRow = svg
.append('g')
.attr('class', 'gridFooter')
.attr('transform', function(d) {;
return 'translate(0 ' + (params.header.height + params.grid.height) + ')';
})
// a group for every column
.selectAll('g')
.data(metaData.columnInfo)
.enter();
var footerGroups = footerRow
.append('g')
.attr('width', params.cell.width)
.attr('height', params.grid.height)
.attr('class', function(d) {
return 'cell col-' + d.field;
})
.attr('transform', function(d) {
var xvalue = metaData.colKeys.indexOf(d.field);
var x = xvalue * (params.cell.width + params.cell.paddingX);
return 'translate(' + x + ' 0)';
});
// create containers for ticks
var axis = footerGroups
.append('g')
.attr('class', function(d) {
return 'axis axis-' + d.field;
})
.attr('transform', function(d) {
return 'translate(0 ' + (params.footer.marginY + params.footer.paddingY) + ')';
});
// define how ticks are created
var createTicks = function (headerGroups) {
var columnInfo = headerGroups.data();
$.each(columnInfo, function(i, val) {
d3.select('.axis-' + val.field)
.call(d3.svg.axis()
.scale(scaleByProperty[val.field])
.ticks(4)
// TODO: add in function for properly displaying the scale units (e.g. 1M instead of 1000000)
)
});
};
// create the ticks per column
footerGroups.call(createTicks);
// ----------------------------------------------------
// Render the vertical lines
// ----------------------------------------------------
// draw vertical zeroLine
$.each(metaData.columnInfo, function(i, val) {
d3.select('.gridHeader .col-' + val.field).append("line")
.attr('class', 'zeroLine')
.attr("x1", function (d) {
return scaleByProperty[val.field](0);
})
.attr("x2", function (d) {
return scaleByProperty[val.field](0);
})
.attr("y1", params.header.height)
.attr("y2", params.header.height)
.attr("stroke", params.zeroLine.strokeColor)
.attr("stroke-width", params.zeroLine.strokeWidth)
.attr("fill", "none")
.transition()
.delay(params.zeroLine.delay)
.duration(params.zeroLine.duration)
.attr("y2", params.header.height + params.grid.height);
});
// draw left vertical left margin lines
$.each(metaData.columnInfo, function(i, val) {
d3.select('.gridHeader .col-' + val.field).append("line")
.attr('class', 'zeroLine')
.attr("x1", function (d) {
return scaleByProperty[val.field](minMaxByProperty[val.field].min);
})
.attr("x2", function (d) {
return scaleByProperty[val.field](minMaxByProperty[val.field].min);
})
.attr("y1", params.header.height)
.attr("y2", params.header.height)
.attr("stroke", params.marginLine.strokeColor)
.attr("stroke-width", params.marginLine.strokeWidth)
.attr("fill", "none")
.style("stroke-dasharray", ("3, 3"))
.transition()
.delay(params.marginLine.delay)
.duration(params.marginLine.duration)
.attr("y2", params.header.height + params.grid.height);
});
// draw left vertical right margin lines
$.each(metaData.columnInfo, function(i, val) {
d3.select('.gridHeader .col-' + val.field).append("line")
.attr('class', 'zeroLine')
.attr("x1", function (d) {
return scaleByProperty[val.field](minMaxByProperty[val.field].max);
})
.attr("x2", function (d) {
return scaleByProperty[val.field](minMaxByProperty[val.field].max);
})
.attr("y1", params.header.height)
.attr("y2", params.header.height)
.attr("stroke", params.marginLine.strokeColor)
.attr("stroke-width", params.marginLine.strokeWidth)
.attr("fill", "none")
.style("stroke-dasharray", ("3, 3"))
.transition()
.delay(params.marginLine.delay)
.duration(params.marginLine.duration)
.attr("y2", params.header.height + params.grid.height);
});
// shift entire grid
d3.select('.grid')
.attr('transform', 'translate(0 ' + params.header.height + ')');
var updateRowOrder = function (rowsInNewOrder) {
// update row keys order
metaData.rowKeys = rowsInNewOrder.data().map(function(val) {
return val.key;
})
d3.selectAll('.zeroLine, .marginLine')
.transition()
.duration(params.sort.duration * .1)
.attr('y2', params.header.height + params.chart.height * .5 )
.transition()
.delay(params.sort.duration * .1)
.duration(params.sort.duration * .9)
.attr('y2', params.header.height + params.grid.height);
// translate each row to be in the right position
rowsInNewOrder
.transition()
.duration(function(d) {
if (params.sort.animate) {
var fractionOfDuration = params.sort.duration / metaData.rowKeys.length;
return fractionOfDuration * metaData.rowKeys.indexOf(d.key);
} else {
return 0;
}
})
// TODO: add in appropriate dalays by row index to better vizualize it
.attr('transform', function(d) {
var yvalue = metaData.rowKeys.indexOf(d.key);
var y = yvalue * (params.cell.height + params.cell.paddingY);
return 'translate(0, ' + y + ')';
});
};
// ----------------------------------------------------
// register UI handlers
// ----------------------------------------------------
// sort on click
if (params.sort.sortType > 0) {
d3.selectAll(".headerText").data(metaData.colKeys).on("click", function(key) {
// switch between sort order options (currently asc/dsc: [1, -1])
metaData.incrementSortOrder(params.sort.sortType);
// get a new order for the rows (not yet applied)
d3.selectAll('g.row').sort(function(a,b) {
return (metaData.getSortOrder() * b[key]) - (metaData.getSortOrder() * a[key]);
});
// apply the new sorted order
updateRowOrder(d3.selectAll('g.row'));
});
}
// highlight row on hover
d3.selectAll(".row").on("mouseover", function(d) {
d3.select('.row-' + d.key).selectAll('rect.dataBar')
.transition()
.duration(200)
.style('fill', function(d) {
return d.value < 0 ? params.dataBar.hover.neg : params.dataBar.hover.pos;
});
});
// return after row on hover
d3.selectAll(".row").on("mouseout", function(d) {
d3.select('.row-' + d.key).selectAll('rect.dataBar')
.transition()
.duration(200)
.style('fill', function(d) {
return d.value < 0 ? params.dataBar.color.neg : params.dataBar.color.pos;
});
});
// send row info on row click
d3.selectAll(".row").on("click", function(d) {
// objects
var rowData = d,
rowHTML = $('.row-' + d.key);
// trigger event
$(el).trigger('rowClicked', {rowHTML: rowHTML, rowData: rowData});
});
return el;
};
// ====================================================
// build chart (exposed to public)
// ====================================================
var buildChart = function (options) {
// hook up options to local vars and set defaults
var chartElement = options.el,
data = options.datasource,
columnInfo = options.columns,
chartParams = options.chartParams, // optional
metaDataParams = options.metaDataParams; // optional
// merge params into defaultParams (defaults get overridden if conflicts arise))
chartParams = mergeStaticParams({params: chartParams});
// clean data and return {data: data, metaData: metaData}
// datasource = {data: data, metaData: metaData}
// data = the actual data
var datasource = getData({data: data, columnInfo: columnInfo, metaDataParams: metaDataParams, chartParams: chartParams});
// add in params that required calculations from data/metaData
chartParams = mergeDynamicParams({params: chartParams, el: chartElement, data: datasource.data, metaData: datasource.metaData});
// draw the chart
// params: (data, metaData, element)
var chart = drawChart({
data: datasource.data,
metaData: datasource.metaData,
el: $(chartElement)[0],
params: chartParams
});
return chart;
};
// if this is used as a module to extend a current module, namespace the returned object {d3chart: returnedObject}
return {
// expose private methods to be public
buildChart: buildChart
}
}
[
{
"a": "-1",
"b": "5",
"c": "1.35",
"d": "500"
},
{
"a": "2",
"b": "100",
"c": "5",
"d": "77"
},
{
"a": "3",
"b": "-30",
"c": "-13.5",
"d": "1000"
},
{
"a": "2",
"b": "58",
"c": "31",
"d": "181"
},
{
"a": "7",
"b": "12",
"c": "1.2",
"d": "564"
},
{
"a": "3",
"b": "77",
"c": ".37",
"d": "274"
},
{
"a": "3",
"b": "-10",
"c": "-1.35",
"d": "365"
},
{
"a": "2",
"b": "83",
"c": "13",
"d": "210"
},
{
"a": "4",
"b": "23",
"c": "0.3",
"d": "640"
},
{
"a": "8",
"b": "51",
"c": ".25",
"d": "740"
}
]
<!DOCTYPE html>
<meta charset="utf-8">
<style type="text/css">
/* move CSS to elsewhere */
/*.dataBar.neg {
fill: orange
}
.dataBar.pos {
fill: steelblue
}*/
</style>
<div class="js-chart" style="width:100%; height:400px"></div>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="http://d3js.org/d3.v2.min.js"></script>
<script src="../d3chart.js"></script>
<script>
// build chart
var sendDataToD3Chart = function (data) {
var d3chart = new D3Chart();
var chart = d3chart.buildChart({
el: '.js-chart',
datasource: { // "datasource: data " is also valid
data: data
},
columns:
// example json for following columns
// [
// {
// "a": "-1",
// "b": "5",
// "c": "1.35",
// "d": "1187"
// },
// ...
// ]
[
{
title: "Alpha", // jQuery encodes this, so HTML characters are displayed literally
field: "a" // field name (no spaces, no special characters)
},
{
title: "Beta",
field: "b"
},
{
title: "Gamma",
field: "c"
},
{
title: "Delta",
field: "d"
}
],
chartParams: {}, // optional
metaDataParams: {} // optional
});
// listen to events on element
var rowClicked = function (event, data) {
console.log(data);
};
$(chart).on('rowClicked', rowClicked);
};
// fetch data
// common implementations will be more robust
var data = $.getJSON('dummy-data.json')
.done(sendDataToD3Chart);
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment