Skip to content

Instantly share code, notes, and snippets.

@PatMartin
Last active January 29, 2017 08:18
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 PatMartin/2f91041cb9c3fdcd3b605608ca3dc5a7 to your computer and use it in GitHub Desktop.
Save PatMartin/2f91041cb9c3fdcd3b605608ca3dc5a7 to your computer and use it in GitHub Desktop.
Bounded Columns

Bounded Columns : Creating Reusable Charts

This is a derived work from Micah Stubb's Bounded Force block, which in turn was derived from work from Ben Tucker.

The intent of this block is to take the previous examples and make it more generic and hence more reusable without requiring a full blown component framework like dex.js, NVD3 or C3. I will highlight the core changes and rationale behind them.

This is not intended to be a criticism of the base example. In fact, the opposite is true. I picked it because the visualization was interesting, the code was clean, concise and easy to follow. Nor is my way necessarily the right way and my opinions are just that, opinions.

If sufficient interest exists, I may take this example further in a secondary gist.

Cool Examples vs Reusable Charts

While there are a plethora of amazing D3 examples out there in the wild, there is usually quite a bit of work involved in adapting them to work for the general case.

Generally, the issues derive from assumptions which work for a specific case but fall apart when the visualization is adapted to a different dataset or perhaps to a different resolution, font size. You name it, it will break it.

Usually these degradations are quite small, but cumulate into a visual which isn't quite right. For example, changing a font may cause text not to misalign with the entity it is annotating and visually mislead the user to believe that it is annotating something else. Perhaps the author assumed axis data was in a currency format while your data is numerical causing a unit mismatch. Reusable charts should provide reasonable behavior under diverse circumstances and provide the user with the ability to easily change that which needs changing.

My Approach

As I stated previously, this is my approach to making a reusable chart from a very nice example. It works for me, and perhaps may work for others as well.

Step 1 : Get the example running

While it may sound obvious, its easier to adapt a running version of a visual rather than skipping the step and getting the adapted chart to work. So, I always start out by first getting the example running locally and get to know the code a bit and gaining solid a comfort level.

Step 2 : Analyze the running example

Walk through the code and get a general feel for what is going on.

Make small changes to pieces

I call this "poking it with a stick" step. Changing a hard-coded parameter from one value to another and observing the resulting behavior. Comment it's behavior if it's shocking and maybe even if it isn't. Don't underestimate the value of emperical observation.

If you break it, Control-Z is your friend; always having a working example in your buffer.

Poor man's change control

Often I'm too lazy to create a copy of a file I am changing and I know I am about to undertake a potentially disastrous change, so I'll create a milestone comment which makes it easy to Control-Z back to that checkpoint.

  // REM: Beware all ye who enter here...no, really, stop control-z'ing
  // here if you get into trouble.

When I am done, I remove all comments tagged with REM.

Understand the data source

One of the most common barriers to reusability is an extremely specialized input data format. D3 is rife with such assumptions, especially when dealing with hierarchical data.

Get an understanding of the format of the data input. If there are transformations on the data via methods such as d3.nest() or d3.stratify(), consider stepping through with a debugger observing the changes.

If it's JSON data the debugger can be cumbersome, perhaps convert it to a string via a call to console.log(JSON.stringify(myData)).

Luckily, this example has no such data conversions.

Identify the assumptions

In this case, there were not too many assumptions. The force settings are generic enough to work for most cases. Probably the main assumption for this chart is that of data-source format and potential CSS side effects.

Datasource Assumptions

{  
  "nodes":[{
      "name":"Agricultural 'waste'",
      "depth": 0
    },
    {  
      "name":"Bio-conversion",
      "depth": 1
    },
  /// Tons more entries...
  ],
  "links": [{  
      "source":0,
      "target":1,
      "value":124.729
    },
    {  
      "source":1,
      "target":2,
      "value":0.597
    },
  // Tons more entries...
  ]
}

This simplifies the example since it is the natural form that the force layout expects, however, there are not too many datasources out there which are compatible with this format without significant amount of transformation.

It would probably be better to accept a generic form of data such as some form of csv and translate it internally to the form expected by the chart.

Making the expected datasource more generic will be a topic in a subsequent block; there's enough to talk about in this one.

CSS Assumptions

circle {
  stroke-width: 1.5px;
}

line {
  stroke: #999;
}

These style all circles and lines a particular way. While this is great for an example, the world of a generic chart is much larger and it must be possible for multiple charts to coexist on a single page.

This CSS will alter any other charts which happen to contain circles; a potentially disastrous side-effect on a page with multiple visuals.

Therefore it is great design if it is possible to:

  • Style an individual chart.
  • Style an individual class of chart.
  • Style them via CSS or dynamically.

Given no styling at all, the generic chart should also provide a reasonably attractive appearance.

While this example is clean in this respect, further CSS assumptions can sometimes lurk within the code in the form of stuff like:

d3.selectAll('circle')
  .style('some-setting', 'some-setting-value');

DOM Assumptions

The main assumption lies in the svg's insertion point into the DOM; which is appended as a child of the body of the page with no facility for user defined insertion points.

var svg = d3.select("body").append("svg")
       .attr("width", width)
       .attr("height", height);

Step 3: Separate configuration from code

Here I create a configuration object and replace hard-coded variable refernces to the appopriate configuration entry. I nest the configuration to form a logical and intuitive configuration data structure.

Configurable force layout

For example, the original code initiated the force layout as follows;

var force = d3.layout.force()
    .gravity(0.05)
    .charge(-50)
    .linkDistance(50)
    .size([width, height])
    .linkStrength(0.005)
    .friction(0.9)
    .theta(0.8)
    .alpha(0.1);

Becomes a much more configurable:

var config = {
  'force': {
    'gravity': 0.05,
    'charge': -50,
    'size': {
      'width': 960,
      'height': 500
    },
    'link': {
      'distance': 50,
      'strength': 0.005
    },
    'friction': 0.9,
    'theta': 0.8,
    'alpha': 0.1
  },
  // more configuration...
};

// Fully configurable
var force = d3.layout.force()
  .gravity(config.force.gravity)
  .charge(config.force.charge)
  .linkDistance(config.force.link.distance)
  .size([config.force.size.width, config.force.size.height])
  .linkStrength(config.force.link.strength)
  .friction(config.force.friction)
  .theta(config.force.theta)
  .alpha(config.force.alpha);

Also note that I prefer logical namespaces to help isolate naming collisions which sometimes occur.

It is easy to conceive of a configuration model which lends itself well to external tool configuration by providing configuration in addition to hints such as type and perhaps max and min values for auto-population of configuration sliders and such. However, that's beyond the scope of this example.

CSS tagging the chart.

One key aspect of the configuration are the CSS tags. I suggest providing configurable tagging for at the following 3 things:

  1. The container node.
  2. The chart svg id.
  3. The chart svg class.
var config = {
  'parent': '#BoundedColumnsParent',
  'id': 'BoundedColumnsId',
  'class': 'BoundedColumnsClass'
  // More config...
};

// More code...
draw(config) {
  // Other code...

  // Ensure another version of this chart isn't in place.
  d3.select(config.parent)
    .selectAll('*').remove();
    
  // Create the CSS tagged svg.
  var svg = d3.select(config.parent)
    .append("svg")
    .attr('id', config.id)
    .attr('class', config.class)
    .attr("width", config.width)
    .attr("height", config.height);
  // More code...
};

Note that in this example, I am not accounting for object constancy. For simplicity sake, when I make multiple calls to draw, I take the simple approach of removing the old and creating a new chart from scratch.

In a later article I will transform this into a model which observes full object constancy via the use of the D3's enter, exit and transition facilities.

One more important point, since we've tagged the chart, it's now possible to do stuff like this:

    #BoundedColumnsId circle {
        stroke-width: 1px;
    }

Here we set the circle stroke width to 1 pixel for circles who have a parent with the id of 'BoundedColumnsId'. This allows us to style circles within this chart and this chart alone. This is very important if we have multiple visuals on a single page.

    .BoundedColumnsClass circle {
        stroke-dasharray: '1 1';
    }

Here we're setting the circle stroke to a dotted line for all charts of this class. This is great if we wish to express a style for all charts of a type without having to duplicate the configuration over and over.

Extensible configuration

Also, rather than assume we know the aspects of what the user will wish to configure dynamically, we can define attribute and style objects within the configuration as follows:

var config = {
  // snip
  'line': {
    'style': { 'stroke': 'black', 'stroke-opacity': 0.5 },
    'attributes': {}
  },
    // snip
  };

  var link = svg.selectAll("line")
    .data(graph.links)
    .enter()
    .append("line")
    .style(config.line.style)
    .attr(config.line.attributes);

Here we are setting the stroke of the line to black with 50% opacity while leaving the configuration open for the user to send in other style settings. We set no attributes on the line, however allow a placeholder for the user to do so. The user may also override our existing settings if they so desire.

Step 4: Encapsulate code, expose config

I do this simply by wrapping the code which draws the chart in a draw method taking the configuration as an argument.

var config = {
  // Same old, same old...
};

draw(config);

function draw(config) {
  // Same old, same old
}

Step 5: HTML hooks

Now that we have our chart's configuration exposed and it's code encapsulated, we can easily hook it into HTML facilities such as range sliders to dynamically control the chart without too much ceremony.

Here are our dynamic controls for radius, gravity and the charge between the circles. Each slider simply changes the configuration object and passes it back into the draw() routine. What could be easier!

<div>
    <label for="radiusSlider">Radius</label>
    <input type="range" min="1" max="20" value="6" id="radiusSlider"
           step="1"
           oninput="config.radius=value;draw(config);document.querySelector('#radius').value = value;">
    <output for="radiusSlider" id="radius">6</output>
    
    <label for="gravitySlider">Gravity</label>
    <input type="range" min="0" max="1" value="0.05" id="gravitySlider"
           step=".01"
           oninput="config.force.gravity=value;draw(config);document.querySelector('#gravity').value = value;">
    <output for="gravitySlider" id="gravity">.05</output>

    <label for="chargeSlider">Charge</label>
    <input type="range" min="-1000" max="1000" value="-50" id="chargeSlider"
           step="1"
           oninput="config.force.charge=value;draw(config);document.querySelector('#charge').value = value;">
    <output for="chargeSlider" id="charge">-50</output>
</div>

Final thoughts

In the movie the Gladiator upon seeing the great Colliseum of Rome, the slave Juba stares up with reverent awe and states "I didn't know that men could build such things...". That's the way I felt the first time I downloaded D3 and saw data truly come to life in front of my eyes. It took me 10 minutes to get my jaw off of the floor.

The code underneath was small, succinct, and appeared to be a combination of Javascript and some sort of hoodoo magic. It was daunting.

I couldn't be more thankful for the work of Mike Bostock and the vibrant, helpful and enthusiastic community around D3.

If I have omitted some important point, feel free to contact me on twitter.

  • Pat
{
"nodes":[
{
"name":"Agricultural 'waste'",
"depth": 0
},
{
"name":"Bio-conversion",
"depth": 1
},
{
"name":"Liquid",
"depth": 2
},
{
"name":"Losses",
"depth": 7
},
{
"name":"Solid",
"depth": 2
},
{
"name":"Gas",
"depth": 2
},
{
"name":"Biofuel imports",
"depth": 0
},
{
"name":"Biomass imports",
"depth": 0
},
{
"name":"Coal imports",
"depth": 0
},
{
"name":"Coal",
"depth": 1
},
{
"name":"Coal reserves",
"depth": 0
},
{
"name":"District heating",
"depth": 4
},
{
"name":"Industry",
"depth": 7
},
{
"name":"Heating and cooling - commercial",
"depth": 7
},
{
"name":"Heating and cooling - homes",
"depth": 7
},
{
"name":"Electricity grid",
"depth": 4
},
{
"name":"Over generation / exports",
"depth": 7
},
{
"name":"H2 conversion",
"depth": 5
},
{
"name":"Road transport",
"depth": 7
},
{
"name":"Agriculture",
"depth": 7
},
{
"name":"Rail transport",
"depth": 7
},
{
"name":"Lighting & appliances - commercial",
"depth": 7
},
{
"name":"Lighting & appliances - homes",
"depth": 7
},
{
"name":"Gas imports",
"depth": 0
},
{
"name":"Ngas",
"depth": 1
},
{
"name":"Gas reserves",
"depth": 0
},
{
"name":"Thermal generation",
"depth": 3
},
{
"name":"Geothermal",
"depth": 0
},
{
"name":"H2",
"depth": 6
},
{
"name":"Hydro",
"depth": 0
},
{
"name":"International shipping",
"depth": 7
},
{
"name":"Domestic aviation",
"depth": 7
},
{
"name":"International aviation",
"depth": 7
},
{
"name":"National navigation",
"depth": 7
},
{
"name":"Marine algae",
"depth": 0
},
{
"name":"Nuclear",
"depth": 0
},
{
"name":"Oil imports",
"depth": 0
},
{
"name":"Oil",
"depth": 1
},
{
"name":"Oil reserves",
"depth": 0
},
{
"name":"Other waste",
"depth": 0
},
{
"name":"Pumped heat",
"depth": 0
},
{
"name":"Solar PV",
"depth": 1
},
{
"name":"Solar Thermal",
"depth": 1
},
{
"name":"Solar",
"depth": 0
},
{
"name":"Tidal",
"depth": 0
},
{
"name":"UK land based bioenergy",
"depth": 0
},
{
"name":"Wave",
"depth": 0
},
{
"name":"Wind",
"depth": 0
}
],
"links":[
{
"source":0,
"target":1,
"value":124.729
},
{
"source":1,
"target":2,
"value":0.597
},
{
"source":1,
"target":3,
"value":26.862
},
{
"source":1,
"target":4,
"value":280.322
},
{
"source":1,
"target":5,
"value":81.144
},
{
"source":6,
"target":2,
"value":35
},
{
"source":7,
"target":4,
"value":35
},
{
"source":8,
"target":9,
"value":11.606
},
{
"source":10,
"target":9,
"value":63.965
},
{
"source":9,
"target":4,
"value":75.571
},
{
"source":11,
"target":12,
"value":10.639
},
{
"source":11,
"target":13,
"value":22.505
},
{
"source":11,
"target":14,
"value":46.184
},
{
"source":15,
"target":16,
"value":104.453
},
{
"source":15,
"target":14,
"value":113.726
},
{
"source":15,
"target":17,
"value":27.14
},
{
"source":15,
"target":12,
"value":342.165
},
{
"source":15,
"target":18,
"value":37.797
},
{
"source":15,
"target":19,
"value":4.412
},
{
"source":15,
"target":13,
"value":40.858
},
{
"source":15,
"target":3,
"value":56.691
},
{
"source":15,
"target":20,
"value":7.863
},
{
"source":15,
"target":21,
"value":90.008
},
{
"source":15,
"target":22,
"value":93.494
},
{
"source":23,
"target":24,
"value":40.719
},
{
"source":25,
"target":24,
"value":82.233
},
{
"source":5,
"target":13,
"value":0.129
},
{
"source":5,
"target":3,
"value":1.401
},
{
"source":5,
"target":26,
"value":151.891
},
{
"source":5,
"target":19,
"value":2.096
},
{
"source":5,
"target":12,
"value":48.58
},
{
"source":27,
"target":15,
"value":7.013
},
{
"source":17,
"target":28,
"value":20.897
},
{
"source":17,
"target":3,
"value":6.242
},
{
"source":28,
"target":18,
"value":20.897
},
{
"source":29,
"target":15,
"value":6.995
},
{
"source":2,
"target":12,
"value":121.066
},
{
"source":2,
"target":30,
"value":128.69
},
{
"source":2,
"target":18,
"value":135.835
},
{
"source":2,
"target":31,
"value":14.458
},
{
"source":2,
"target":32,
"value":206.267
},
{
"source":2,
"target":19,
"value":3.64
},
{
"source":2,
"target":33,
"value":33.218
},
{
"source":2,
"target":20,
"value":4.413
},
{
"source":34,
"target":1,
"value":4.375
},
{
"source":24,
"target":5,
"value":122.952
},
{
"source":35,
"target":26,
"value":839.978
},
{
"source":36,
"target":37,
"value":504.287
},
{
"source":38,
"target":37,
"value":107.703
},
{
"source":37,
"target":2,
"value":611.99
},
{
"source":39,
"target":4,
"value":56.587
},
{
"source":39,
"target":1,
"value":77.81
},
{
"source":40,
"target":14,
"value":193.026
},
{
"source":40,
"target":13,
"value":70.672
},
{
"source":41,
"target":15,
"value":59.901
},
{
"source":42,
"target":14,
"value":19.263
},
{
"source":43,
"target":42,
"value":19.263
},
{
"source":43,
"target":41,
"value":59.901
},
{
"source":4,
"target":19,
"value":0.882
},
{
"source":4,
"target":26,
"value":400.12
},
{
"source":4,
"target":12,
"value":46.477
},
{
"source":26,
"target":15,
"value":525.531
},
{
"source":26,
"target":3,
"value":787.129
},
{
"source":26,
"target":11,
"value":79.329
},
{
"source":44,
"target":15,
"value":9.452
},
{
"source":45,
"target":1,
"value":182.01
},
{
"source":46,
"target":15,
"value":19.013
},
{
"source":47,
"target":15,
"value":289.366
}
]
}
<!DOCTYPE html>
<meta charset="utf-8">
<style>
/*
Targets a named chart. Great if you wish to style one chart differently
than another on a single page without interfering with the styling of
any other circles.
While I prefer to style via API calls for programmable flexibility,
others like to work differently, and having full access to CSS is
quite powerful.
*/
#BoundedColumnsId circle {
stroke-width: 1px;
}
/*
Targets all circles of all instances of BoundedColumnsClass. Great
for styling all circles within charts of this class type at once.
*/
.BoundedColumnsClass circle {
stroke-dasharray: '1 1';
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.0.0-alpha.6/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-slider/9.7.0/css/bootstrap-slider.css" />
<body>
<div>
<label for="radiusSlider">Radius</label>
<input type="range" min="1" max="20" value="6" id="radiusSlider"
step="1"
oninput="config.radius=value;draw(config);document.querySelector('#radius').value = value;">
<output for="radiusSlider" id="radius">6</output>
<label for="gravitySlider">Gravity</label>
<input type="range" min="0" max="1" value="0.05" id="gravitySlider"
step=".01"
oninput="config.force.gravity=value;draw(config);document.querySelector('#gravity').value = value;">
<output for="gravitySlider" id="gravity">.05</output>
<label for="chargeSlider">Charge</label>
<input type="range" min="-1000" max="1000" value="-50" id="chargeSlider"
step="1"
oninput="config.force.charge=value;draw(config);document.querySelector('#charge').value = value;">
<output for="chargeSlider" id="charge">-50</output>
</div>
<div id="BoundedColumnsParent"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.17/d3.min.js"></script>
<script>
var config = {
'parent': '#BoundedColumnsParent',
'id': 'BoundedColumnsId',
'class': 'BoundedColumnsClass',
'height': 500,
'width': 960,
'radius': 6,
'force': {
'gravity': 0.05,
'charge': -50,
'size': {
'width': 960,
'height': 500
},
'link': {
'distance': 50,
'strength': 0.005
},
'friction': 0.9,
'theta': 0.8,
'alpha': 0.1
},
'colorScheme': d3.scale.category20(),
'data': 'data.json',
'background': {
'style': {'fill': 'white', 'stroke': 'none'},
'attributes': {'stroke': 1}
},
'tooltip': {
'style': {
'position': 'absolute',
'z-index': 10,
'visibility': 'hidden'
}
},
'line': {
'style': {
'stroke': 'black',
'stroke-opacity': 0.5
},
'attributes': {}
},
'node': {
'style': {},
'attributes': {}
}
};
draw(config);
function draw(config) {
var force = d3.layout.force()
.gravity(config.force.gravity)
.charge(config.force.charge)
.linkDistance(config.force.link.distance)
.size([config.force.size.width, config.force.size.height])
.linkStrength(config.force.link.strength)
.friction(config.force.friction)
.theta(config.force.theta)
.alpha(config.force.alpha);
d3.select(config.parent)
.selectAll('*').remove();
var svg = d3.select(config.parent)
.append("svg")
.attr('id', config.id)
.attr('class', config.class)
.attr("width", config.width)
.attr("height", config.height);
svg.append('rect')
.attr("width", config.width)
.attr("height", config.height)
.style(config.background.style)
.attr(config.background.attributes);
d3.json(config.data, function (error, graph) {
if (error) throw error;
var tooltip = d3.select(config.parent)
.append("div")
.style(config.tooltip.style);
var link = svg.selectAll("line")
.data(graph.links)
.enter()
.append("line")
.style(config.line.style)
.attr(config.line.attributes);
var node = svg.selectAll("circle")
.data(graph.nodes)
.enter().append("circle")
.attr("r", config.radius - .75)
.style("fill", function (d) {
return config.colorScheme(d.depth);
})
.style("stroke", function (d) {
return d3.rgb(config.colorScheme(d.group)).darker();
})
.call(force.drag);
force
.nodes(graph.nodes)
.links(graph.links)
.on("tick", tick)
.start();
function tick() {
node.each(function (d) {
w = 105 * (1 + d.depth);
d.x -= (0.2 * (d.x - w))
})
node.attr("cx", function (d) {
return d.x = Math.max(config.radius, Math.min(config.width - config.radius, d.x));
})
.attr("cy", function (d) {
return d.y = Math.max(config.radius, Math.min(config.height - config.radius, d.y));
});
link.attr("x1", function (d) {
return d.source.x;
})
.attr("y1", function (d) {
return d.source.y;
})
.attr("x2", function (d) {
return d.target.x;
})
.attr("y2", function (d) {
return d.target.y;
});
}
node
.on("mouseover", function (d) {
return tooltip
.text(d.name)
.style("visibility", "visible");
})
.on("mousemove", function () {
return tooltip
.style("top", (event.pageY - 10) + "px")
.style("left", (event.pageX + 10) + "px");
})
.on("mouseout", function () {
return tooltip
.style("visibility", "hidden")
});
});
}
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment