Skip to content

Instantly share code, notes, and snippets.

@pkowalicki
Last active January 6, 2017 04:28
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save pkowalicki/57d1deb5003baa826d9e21ec4683b058 to your computer and use it in GitHub Desktop.
Save pkowalicki/57d1deb5003baa826d9e21ec4683b058 to your computer and use it in GitHub Desktop.
Pivot table visualization with zoomable partition layout in d3.js
border: no
height: 900
license: gpl-3.0

Pivot table visualization inspired by zoomable partition layout. It is implemented as Bootsrap modal that you can open by clicking at the button above.

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<link href="http://bootswatch.com/spacelab/bootstrap.min.css" rel="stylesheet"/>
<script src="http://code.jquery.com/jquery-1.11.1.min.js" type="text/javascript"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
<script type="text/javascript" src="http://d3js.org/d3.v3.min.js"></script>
<style type="text/css">
.chart {
display: block;
margin: auto;
margin-top: 60px;
font-size: 11px;
}
rect {
stroke: #eee;
fill: #aaa;
fill-opacity: .8;
}
rect.parent {
cursor: pointer;
fill: steelblue;
}
text {
pointer-events: none;
}
#analyticsModal .modal
{
width: 98%;
}
#analyticsModal .modal-dialog
{
width: 98%;
}
#analyticsModal .modal-body
{
overflow-y: auto;
}
.custom-bullet li {
display: block;
}
.custom-bullet li:before
{
/*Using a Bootstrap glyphicon as the bullet point*/
content: "\e080";
font-family: 'Glyphicons Halflings';
font-size: 9px;
float: left;
margin-top: 4px;
margin-left: -17px;
color: #CCCCCC;
}
.row.extra-bottom-padding {
margin-bottom: 20px;
}
</style>
</head>
<body role="document">
<!-- ######## Core chart HTML content ########### -->
<div class="modal fade collapse" id="analyticsModal" tabindex="-1" role="dialog" aria-labelledby="basicModal" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header" style="text-align: center">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h3 class="modal-title" id="analytics-modal-label">Chart title</h3>
</div>
<div class="modal-body">
<div class="container-fluid" id="modal-body-main-content" style="width:100%; height:100%;">
<div class="row" id="config-row">
<!-- content of the config row is generated dynamically based on the parameters -->
</div> <!-- end of config row -->
<div class="alert alert-danger alert-dismissible" id="analytics-modal-no-data-alert" role="alert">
<button type="button" class="close" data-hide="alert" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="text-center"><strong>Error!</strong> Problem with data source. Check data URL and content.</h4>
</div>
</div> <!-- end of modal body container for configuration -->
</div> <!-- end of modal body -->
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
</div>
</div> <!-- end of modal content -->
</div> <!-- end of modal dialog -->
</div> <!-- end of modal -->
<div class="hide analytics-dropdown-pattern">
<div class="input-group pull-left">
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-expanded="false">text<span class="caret"></span></button>
<ul class="dropdown-menu" role="menu">
</ul>
</div>
<span class="label"></span>
</div>
</div>
<div class="hide analytics-show-btn-pattern">
<div>
<button type="button" id="show-vis-button" class="btn btn-primary pull-left" disabled="disabled">Show</button>
</div>
</div>
<div class="hide analytics-text-pattern">
<h4 class="pull-right"> Choose Levels: </h4>
</div>
<!-- ######## End of core chart HTML content ########### -->
<!-- ######## Explanation (readme) content ############# -->
<div class="container-fluid">
<div class="row extra-bottom-padding">
<div class="col-sm-12">
<p class="lead text-left"> Pivot table visualization using Zoomable Partition Layout in <a href="https://d3js.org/">d3.js</a> </p>
<p class="text-left"> Visualization is inspired by
<a href="http://mbostock.github.io/d3/talk/20111018/partition.html">the implementation</a> of partition layout.</p>
<p class="text-left">You can visualize hypothetical data containing sales orders for clothes products. Summarizing aggregation is performed on the number of ordered units for the hierarchy of three dimensions:
</p>
<ul class="custom-bullet">
<li>the sales person placing an order,</li>
<li>the name of the ordered product,</li>
<li>the state of an order (open, closed, archived).</li>
</ul>
<p class="text-left">You can change the hierarchy of dimensions at any time which will trigger visual transition to new data view. Click any field on the chart to ascend and descend.
</p>
<p class="text-left"><a href="product_orders.json">Data source</a> is in flat structure that can be easily retrieved with the likes of 'group by' statement.</p>
</div>
</div>
<div class="row extra-bottom-padding">
<div class="col-sm-12">
<button type="button" class="btn btn-primary center-block" onclick="testProductOrders()">Click to start</button>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<p class="text-left">If time allows I will
migrate it from Bootstrap modal to more generic HTML. In the meantime, if you would like to use it, you need to add
core chart HTML content to your DOM, invoke <code>openVisualizationModal(dimensionsTable, dataSourceURLFn, chartTitle)</code> function and prepare the data in <a href="product_orders.json">such</a> flat structure.
</p>
</div>
</div>
</div>
<!-- ######## End of explanation (readme) content ############# -->
<!-- ######## Core visualization code ######################### -->
<script type="text/javascript" src="pivot_table_vis.js"></script>
<script type="text/javascript">
// opens visualization chart
// arguments: d - table with dimensions
// function(dims) - function returning data source URL
// string - title of the chart
function testProductOrders(){
var d = ["state", "owner", "product"];
if (!openVisualizationModal(d, function(dims){
return "product_orders.json";
}, "Product Orders"))
alert("ERROR. Unable to open visualization.");
}
</script>
</body>
</html>
var dimensions = []; // definition of dimensions
var dimensionsValues = []; // current choice for each dimension
var previousDimValues = []; // previous choice of dimensions values (for rollback in case of error)
var MAX_DIM = 6; // maximum number of dimensions
// titles, labels etc. - strings used in the config controls
var EMPTY_DIM_LABEL_TEXT = "EMPTY";
var DIM_BUTTON_TEXT = "LEVEL";
var DEFAULT_TITLE = "Analytics";
var MODAL_TITLE = "";
var dataSourceURLFn;
//helper property definition for getting the d3 selection size (useful for debugging)
d3.selection.prototype.size = function() {
var n = 0;
this.each(function() { ++n; });
return n;
};
// visualization dimensions
var w = 800,
h = 500;
// scaling functions for x and y coordinates that maps the visualized data domain (values) to visualization viewport size
var x = d3.scale.linear().range([0, w]),
y = d3.scale.linear().range([0, h]);
var kx, ky;
//visualization viewport
var vis = null;
var g = null; // global variable containing the group of visualized 'svg:g' elements
var treeRoot = null; // root for visualized data
var isInChangeDimension = false; // flag indicating that the visualization is in fading out stage
//----- visualization config controls functions comes below -----------------------
//inits drop-downlists with the content based on the passed dimensions
function initConfigDropdowns(){
for (i=1, len=dimensions.length; i<=len; i++)
for (j=0; j<len; j++)
$("div.input-group[dimensionId='" + i + "'] ul")
.append("<li dimensionName='" + dimensions[j] + "'>" +
"<a href='#'>" + dimensions[j] + "</a></li>");
}
// on-click handlers for the config drop-down lists
function registerDimensionsOnClick(){
$("li[dimensionName] > a").click(function(){
var dimensionId = parseInt($(this).parents(".input-group").attr("dimensionId"));
var dimensionValue = $(this).text();
// disable this dimension value in this dropdown
$(this).parent().addClass("disabled"); // disable this dimension value in this dropdown
// remove link block on old value if any
if (dimensionsValues[dimensionId-1] != ""){
$("div.input-group[dimensionId='" + dimensionId + "'] li[dimensionName='" + dimensionsValues[dimensionId-1] + "']")
.removeClass("disabled");
}
// update dimension label
$("div.input-group[dimensionId='" + dimensionId + "'] > span")
.removeClass("label-danger")
.addClass("label-success")
.text(dimensionValue);
var index = dimensionsValues.indexOf(dimensionValue);
if (index != -1){
var updatingDimensionId = index + 1;
$("div.input-group[dimensionId='" + updatingDimensionId + "'] li[dimensionName='" + dimensionValue + "']")
.removeClass("disabled");
$("div.input-group[dimensionId='" + updatingDimensionId + "'] > span")
.removeClass("label-success")
.addClass("label-danger")
.text(EMPTY_DIM_LABEL_TEXT);
dimensionsValues[index] = "";
}
dimensionsValues[dimensionId-1] = dimensionValue;
if (dimensionsValues.indexOf("") == -1){
$("#show-vis-button").prop('disabled', false);
}
else
$("#show-vis-button").prop('disabled', true);
});
}
//creates the modal title based on the chosen dimensions
function buildTitleString(){
var dimensionsOrder = " - ";
for (i=0, len=dimensionsValues.length; i<len; i++){
dimensionsOrder = dimensionsOrder.concat(dimensionsValues[i]);
if (i != len-1) dimensionsOrder = dimensionsOrder.concat(", ");
}
dimensionsOrder = dimensionsOrder.concat(".");
$("#analytics-modal-label").text(MODAL_TITLE + dimensionsOrder);
}
// fires visualization
function registerShowOnClick(){
$("#show-vis-button").click(function(){
d3.json(dataSourceURLFn(dimensionsValues))
.on("progress", function(){
$("#analytics-modal-label").text("Loading data ...");
$("#show-vis-button").text("Loading data ...");
$("#show-vis-button").prop('disabled', true);
})
.get(function(error, root){
if (typeof root === "undefined"){
rollbackDimensions(previousDimValues);
$("#analytics-modal-no-data-alert").show();
return;
}
previousDimValues = dimensionsValues.slice(0);
var data = d3.nest();
function addKey(index) {
data.key(function(d) { return d[dimensionsValues[index]]; })
}
for(var i=0; i<dimensionsValues.length-1; i++) {
addKey(i);
}
var visObj = {"key": root.name, "values" : data.entries(root.data)};
var statisticsName = root.statistics;
if ($("div.chart g").length == 0){
visualize(visObj, statisticsName);
} else {
fadeOut();
d3.timer(function(){
if ($("div.chart g").length == 0){
visualize(visObj, statisticsName);
return true;
}
});
}
}); // end of xhr.get
}); // end of on-click
}
// restores previously chosen dimensions in case of error (values, dropdown links' states as well as labels are rollbacked)
function rollbackDimensions(values){
$("#analytics-modal-label").text(MODAL_TITLE);
$("#show-vis-button").text("Show");
dimensionsValues = values.slice(0);
$("li[dimensionName]").removeClass("disabled");
if (values.indexOf("") == -1)
{
$("#show-vis-button").prop('disabled', false);
for (i=0, len=values.length; i<len; i++){
$("div.input-group[dimensionId='" + (i+1) + "'] li[dimensionName='" + values[i] + "']")
.addClass("disabled");
$("div.input-group[dimensionId='" + (i+1) + "'] > span")
.addClass("label-success")
.text(values[i]);
}
}
else{
$("div.input-group[dimensionId] > span")
.addClass("label-danger")
.text(EMPTY_DIM_LABEL_TEXT);
$("#show-vis-button").prop('disabled', true);
}
}
// init all the text fields in the modal (label, dimensions buttons' texts and modal title)
function initModalTexts(){
$("div.input-group[dimensionId] > span")
.addClass("label-danger")
.text(EMPTY_DIM_LABEL_TEXT);
$("div.input-group-btn button:first-child", $("#analyticsModal"))
.each(function(i){
$(this)
.html(DIM_BUTTON_TEXT
+ " "
+ $(this).parents(".input-group").attr("dimensionId")
+ " <span class='caret'></span>");
});
$("#analytics-modal-label").html(MODAL_TITLE);
}
// this function creates dynamically the config row based on the passed dimensions
function initConfigRow(){
var sideWidth = MAX_DIM - dimensions.length;
$(".analytics-text-pattern")
.clone()
.removeClass("hide analytics-text-pattern")
.addClass("col-sm-" + sideWidth)
.appendTo($("#config-row", $("#analyticsModal")));
for (i=0, len=dimensions.length; i<len; i++)
$(".analytics-dropdown-pattern")
.clone()
.removeClass("hide analytics-dropdown-pattern")
.addClass("col-sm-2")
.children(".input-group")
.attr("dimensionId",i+1)
.parent()
.appendTo($("#config-row", $("#analyticsModal")));
$(".analytics-show-btn-pattern")
.clone()
.removeClass("hide analytics-show-btn-pattern")
.addClass("col-sm-" + sideWidth)
.appendTo($("#config-row", $("#analyticsModal")));
}
// this function shall be called from the external code to open and init the analytics modal
// it expects the table of dimension names as strings e.g. ["dim1", "dim2", "dim3"]
// URL of data source and title of the analytics
function openVisualizationModal(dims, getDataSourceURL, title){
if(typeof dims === "undefined" || !$.isArray(dims)){
console.log("Wrong arguments passed to openning analytics modal: dimensions table incorrect");
return false;
}
if(!getDataSourceURL){
console.log("Wrong arguments passed to openning analytics modal: please provide function returning your data URL");
return false;
}
dataSourceURLFn = getDataSourceURL;
dimensions = dims;
if (typeof title === "undefined") MODAL_TITLE = DEFAULT_TITLE
else MODAL_TITLE = title;
$("#analyticsModal").one("show.bs.modal", function() {
var height = $(window).height() - 100;
$(this).find(".modal-body").css("max-height", height);
for(i=0,len=dimensions.length; i<len; i++){
dimensionsValues[i] = "";
previousDimValues[i] = "";
}
$("#show-vis-button")
.prop('disabled', true)
.text("Show");
$("button[data-hide='alert']", $("#analyticsModal")).click(function(){
$("#analytics-modal-no-data-alert").hide();
});
$("#analytics-modal-no-data-alert").hide();
// creates the container and SVG placeholder for visualization
vis = d3.select(".modal-body").append("div")
.attr("class", "chart")
.style("width", w + "px")
.style("height", h + "px")
.append("svg:svg")
.attr("width", w)
.attr("height", h);
initConfigRow();
initModalTexts();
initConfigDropdowns();
registerDimensionsOnClick();
registerShowOnClick();
});
// register handler for clearing things up after modal is closed
$("#analyticsModal").one("hidden.bs.modal", function() {
// removes list items for dimensions choice (and all the on-click handlers)
$("div.input-group li", $("#analyticsModal")).remove();
// remove chart container where visualization is displayed and all linked event handlers
$("div.chart", $("#analyticsModal")).remove();
// remove on-click handler for Show button
$("#show-vis-button").off("click");
//remove alert close on-click
$("button[data-hide='alert']", $("#analyticsModal")).off("click");
// empty config row
$("#config-row > div", $("#analyticsModal")).remove();
dimensions = [];
dimensionsValues = [];
previousDimValues = [];
});
// show the modal
$("#analyticsModal").modal('show');
return true;
}
// ----------------- Visualization code comes below -----------------------------
// helper function to apply callback at the end of all independent transitions scheduled on a group of elements
function endall(transition, callback) {
var n = 0;
transition
.each(function() { ++n; })
.each("end", function() { if (!--n) callback.apply(this, arguments); });
}
// definition of the function visualizing data attached to the root of the JSON tree object
function visualize(root, statisticsName){
isInChangeDimension = false;
//d3.parition initialization, setting value and children accessors
var partition = d3.layout.partition()
.value(function(d) { return d[statisticsName]; })
.children(function(d) { return d.values; });
x = d3.scale.linear().range([0, w]);
y = d3.scale.linear().range([0, h]);
$("#analytics-modal-label").text("Rendering ...");
$("#show-vis-button").text("Rendering ...");
$("#show-vis-button").prop('disabled', true);
treeRoot = root;
g = vis.selectAll("g")
.data(partition.nodes(root))
.enter().append("svg:g")
.attr("transform", function(d) { return "translate(" + x(d.y) + ", 0)"; })
.on("click", click);
kx = w / root.dx;
ky = h / 1;
g.append("svg:rect")
.attr("width", root.dy * kx)
.attr("height", 0)
.attr("class", function(d){ return d.children ? "parent" : "child"; });
g.append("svg:text")
.attr("transform", "translate(8,0)")
.attr("dy", ".35em")
.style("opacity", 0)
.text(function(d) {
return (d.children ? d.key : d[dimensionsValues[dimensionsValues.length-1]]) + ": " + d.value;
});
var t = g.transition()
.duration(750)
.attr("transform", function(d) { return "translate(" + x(d.y) + "," + y(d.x) + ")"; })
.call(endall, function(){
buildTitleString();
$("#show-vis-button").text("Show");
$("#show-vis-button").prop('disabled', false);
});
t.select("rect")
.attr("width", root.dy * kx)
.attr("height", function(d) { return d.dx * ky; });
t.select("text")
.attr("transform", function(d){
return "translate(8," + d.dx * ky / 2 + ")";
})
.attr("dy", ".35em")
.style("opacity", function(d) { return d.dx * ky > 12 ? 1 : 0; });
} // end of visualize function
// data element on-click handler to zoom in/out the visualization
function click(d) {
$("#show-vis-button").prop('disabled', true);
if (!d.children)
d = treeRoot;
kx = (d.y ? w - 40 : w) / (1 - d.y);
ky = h / d.dx;
x.domain([d.y, 1]).range([d.y ? 40 : 0, w]);
y.domain([d.x, d.x + d.dx]);
var gTransition = g.transition()
.duration(1500)
.attr("transform", function(d) { return "translate(" + x(d.y) + "," + y(d.x) + ")"; })
.call(endall, function(){
if (!isInChangeDimension && dimensionsValues.indexOf("") == -1)
$("#show-vis-button").prop('disabled', false);
});
var rectTransition = gTransition.select("rect")
.attr("width", d.dy * kx)
.attr("height", function(d) { return d.dx * ky; });
var textTransition = gTransition.select("text")
.attr("transform", function(d){
return "translate(8," + d.dx * ky / 2 + ")";
})
.style("opacity", function(d) { return d.dx * ky > 12 ? 1 : 0; });
if (isInChangeDimension){
gTransition.transition()
.duration(1500)
.attr("transform", function(d) { return "translate(" + x(d.y) + ", 0)"; })
.remove()
.call(endall, function(){
if (dimensionsValues.indexOf("") == -1)
$("#show-vis-button").prop('disabled', false);
});
rectTransition.transition()
.duration(1500)
.attr("height", 0);
textTransition.transition()
.duration(1500)
.attr("transform", "translate(8,0)")
.style("opacity",0);
}
}
// fading out the current visualization
function fadeOut() {
$("#analytics-modal-label").text("Rendering ...");
$("#show-vis-button").text("Rendering ...");
$("#show-vis-button").prop('disabled', true);
isInChangeDimension = true;
if (!(kx == w && ky == h)) click(treeRoot);
else {
var t = g.transition()
.duration(2000)
.attr("transform", function(d) { return "translate(" + x(d.y) + ", 0)"; })
.remove()
.call(endall, function(){
$("#show-vis-button").prop('disabled', false);
});
t.select("rect")
.attr("height", 0);
t.select("text")
.attr("transform", "translate(8,0)")
.style("opacity",0);
}
}
{
"name": "product orders total",
"statistics" : "no_products",
"data": [
{
"no_products": 100,
"state": "open",
"owner": "Jacob",
"product": "trousers"
},
{
"no_products": 70,
"state": "open",
"owner": "Jacob",
"product": "belt"
},
{
"no_products": 140,
"state": "open",
"owner": "Noah",
"product": "belt"
},
{
"no_products": 100,
"state": "open",
"owner": "Noah",
"product": "jacket"
},
{
"no_products": 50,
"state": "open",
"owner": "Noah",
"product": "t-shirt"
},
{
"no_products": 70,
"state": "open",
"owner": "William",
"product": "jacket"
},
{
"no_products": 60,
"state": "open",
"owner": "William",
"product": "cap"
},
{
"no_products": 50,
"state": "open",
"owner": "William",
"product": "belt"
},
{
"no_products": 190,
"state": "open",
"owner": "Emily",
"product": "trousers"
},
{
"no_products": 140,
"state": "open",
"owner": "Emily",
"product": "belt"
},
{
"no_products": 100,
"state": "open",
"owner": "Emily",
"product": "t-shirt"
},
{
"no_products": 70,
"state": "open",
"owner": "Emma",
"product": "jacket"
},
{
"no_products": 140,
"state": "open",
"owner": "Emma",
"product": "cap"
},
{
"no_products": 180,
"state": "closed",
"owner": "Jacob",
"product": "cap"
},
{
"no_products": 140,
"state": "closed",
"owner": "Jacob",
"product": "jacket"
},
{
"no_products": 80,
"state": "closed",
"owner": "Jacob",
"product": "belt"
},
{
"no_products": 190,
"state": "closed",
"owner": "William",
"product": "t-shirt"
},
{
"no_products": 110,
"state": "closed",
"owner": "William",
"product": "cap"
},
{
"no_products": 40,
"state": "closed",
"owner": "William",
"product": "trousers"
},
{
"no_products": 200,
"state": "closed",
"owner": "Emily",
"product": "belt"
},
{
"no_products": 100,
"state": "closed",
"owner": "Emily",
"product": "cap"
},
{
"no_products": 130,
"state": "closed",
"owner": "Noah",
"product": "t-shirt"
},
{
"no_products": 100,
"state": "closed",
"owner": "Noah",
"product": "jacket"
},
{
"no_products": 190,
"state": "closed",
"owner": "Emma",
"product": "cap"
},
{
"no_products": 140,
"state": "closed",
"owner": "Emma",
"product": "jacket"
},
{
"no_products": 30,
"state": "closed",
"owner": "Emma",
"product": "trousers"
},
{
"no_products": 20,
"state": "closed",
"owner": "Emma",
"product": "t-shirt"
},
{
"no_products": 200,
"state": "archived",
"owner": "Jacob",
"product": "t-shirt"
},
{
"no_products": 140,
"state": "archived",
"owner": "Jacob",
"product": "cap"
},
{
"no_products": 80,
"state": "archived",
"owner": "Jacob",
"product": "belt"
},
{
"no_products": 20,
"state": "closed",
"owner": "Jacob",
"product": "trousers"
},
{
"no_products": 110,
"state": "archived",
"owner": "William",
"product": "belt"
},
{
"no_products": 80,
"state": "archived",
"owner": "William",
"product": "trousers"
},
{
"no_products": 20,
"state": "archived",
"owner": "William",
"product": "cap"
},
{
"no_products": 100,
"state": "archived",
"owner": "Emily",
"product": "t-shirt"
},
{
"no_products": 90,
"state": "closed",
"owner": "Emily",
"product": "trousers"
},
{
"no_products": 130,
"state": "archived",
"owner": "Noah",
"product": "jacket"
},
{
"no_products": 100,
"state": "archived",
"owner": "Noah",
"product": "cap"
},
{
"no_products": 40,
"state": "archived",
"owner": "Noah",
"product": "trousers"
},
{
"no_products": 130,
"state": "archived",
"owner": "Emma",
"product": "trousers"
},
{
"no_products": 120,
"state": "archived",
"owner": "Emma",
"product": "t-shirt"
},
{
"no_products": 100,
"state": "archived",
"owner": "Emma",
"product": "cap"
}, {
"no_products": 60,
"state": "archived",
"owner": "Emma",
"product": "belt"
}, {
"no_products": 10,
"state": "archived",
"owner": "Emma",
"product": "jacket"
}
]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment