Skip to content

Instantly share code, notes, and snippets.

@curran
Last active August 29, 2015 14:16
Show Gist options
  • Save curran/015d34d6d3d562877e51 to your computer and use it in GitHub Desktop.
Save curran/015d34d6d3d562877e51 to your computer and use it in GitHub Desktop.
Data Canvas Part 3 - Bar Chart
// A reusable bar chart module.
// Draws from D3 bar chart example http://bl.ocks.org/mbostock/3885304
// Curran Kelleher March 2015
define(["d3", "model", "lodash"], function (d3, Model, _) {
// A representation for an optional Model property that is not specified.
// This allows the "when" approach to support optional properties.
// Inspired by Scala's Option type.
// See http://alvinalexander.com/scala/using-scala-option-some-none-idiom-function-java-null
var None = "__none__";
// The constructor function, accepting default values.
return function BarChart(defaults) {
// Create a Model instance for the bar chart.
// This will serve as the public API for the visualization.
var model = Model();
// Create the SVG element from the container DOM element.
model.when("container", function (container) {
model.svg = d3.select(container).append('svg');
});
// Adjust the size of the SVG based on the `box` property.
model.when(["svg", "box"], function (svg, box) {
svg.attr("width", box.width).attr("height", box.height);
});
// Create the SVG group that will contain the visualization.
model.when("svg", function (svg) {
model.g = svg.append("g");
});
// Adjust the translation of the group based on the margin.
model.when(["g", "margin"], function (g, margin) {
g.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
});
// Create the title text element.
model.when("g", function (g){
model.titleText = g.append("text").attr("class", "title-text");
});
// Center the title text when width changes.
model.when(["titleText", "width"], function (titleText, width) {
titleText.attr("x", width / 2);
});
// Update the title text based on the `title` property.
model.when(["titleText", "title"], function (titleText, title){
titleText.text(title);
});
// Update the title text offset.
model.when(["titleText", "titleOffset"], function (titleText, titleOffset){
titleText.attr("dy", titleOffset + "em");
});
// Compute the inner box from the outer box and margin.
// See Margin Convention http://bl.ocks.org/mbostock/3019563
model.when(["box", "margin"], function (box, margin) {
model.width = box.width - margin.left - margin.right;
model.height = box.height - margin.top - margin.bottom;
});
// Generate a function for getting the X value.
model.when(["data", "xAttribute"], function (data, xAttribute) {
model.getX = function (d) { return d[xAttribute]; };
});
// Handle sorting.
model.when(["sortField", "sortOrder", "data"], function (sortField, sortOrder, data){
var sortedData = _.sortBy(data, sortField);
if(sortOrder === "descending"){
sortedData.reverse();
}
model.sortedData = sortedData;
});
// Compute the domain of the X attribute.
model.when(["sortedData", "getX"], function (sortedData, getX) {
model.xDomain = sortedData.map(getX);
});
// Compute the X scale.
model.when(["xDomain", "width", "barPadding"], function (xDomain, width, padding) {
model.xScale = d3.scale.ordinal().domain(xDomain).rangeRoundBands([0, width], padding);
});
// Generate a function for getting the scaled X value.
model.when(["data", "xScale", "getX"], function (data, xScale, getX) {
model.getXScaled = function (d) { return xScale(getX(d)); };
});
// Set up the X axis.
model.when("g", function (g) {
model.xAxisG = g.append("g").attr("class", "x axis");
model.xAxisText = model.xAxisG.append("text").style("text-anchor", "middle");
});
// Move the X axis label based on its specified offset.
model.when(["xAxisText", "xAxisLabelOffset"], function (xAxisText, xAxisLabelOffset){
xAxisText.attr("dy", xAxisLabelOffset + "em");
});
// Update the X axis transform when height changes.
model.when(["xAxisG", "height"], function (xAxisG, height) {
xAxisG.attr("transform", "translate(0," + height + ")");
});
// Center the X axis label when width changes.
model.when(["xAxisText", "width"], function (xAxisText, width) {
xAxisText.attr("x", width / 2);
});
// Update the X axis based on the X scale.
model.when(["xAxisG", "xScale"], function (xAxisG, xScale) {
xAxisG.call(d3.svg.axis().orient("bottom").scale(xScale));
});
// Update X axis label.
model.when(["xAxisText", "xAxisLabel"], function (xAxisText, xAxisLabel) {
xAxisText.text(xAxisLabel);
});
// Generate a function for getting the Y value.
model.when(["data", "yAttribute"], function (data, yAttribute) {
model.getY = function (d) { return d[yAttribute]; };
});
// Compute the domain of the Y attribute.
// Allow the API client to optionally specify fixed min and max values.
model.yDomainMin = None;
model.yDomainMax = None;
model.when(["data", "getY", "yDomainMin", "yDomainMax"],
function (data, getY, yDomainMin, yDomainMax) {
if(yDomainMin === None && yDomainMax === None){
model.yDomain = d3.extent(data, getY);
} else {
if(yDomainMin === None){
yDomainMin = d3.min(data, getY);
}
if(yDomainMax === None){
yDomainMax = d3.max(data, getY);
}
model.yDomain = [yDomainMin, yDomainMax]
}
});
// Compute the Y scale.
model.when(["data", "yDomain", "height"], function (data, yDomain, height) {
model.yScale = d3.scale.linear().domain(yDomain).range([height, 0]);
});
// Generate a function for getting the scaled Y value.
model.when(["data", "yScale", "getY"], function (data, yScale, getY) {
model.getYScaled = function (d) { return yScale(getY(d)); };
});
// Set up the Y axis.
model.when("g", function (g) {
model.yAxisG = g.append("g").attr("class", "y axis");
model.yAxisText = model.yAxisG.append("text")
.style("text-anchor", "middle")
.attr("transform", "rotate(-90)")
.attr("y", 0);
});
// Move the Y axis label based on its specified offset.
model.when(["yAxisText", "yAxisLabelOffset"], function (yAxisText, yAxisLabelOffset){
yAxisText.attr("dy", "-" + yAxisLabelOffset + "em")
});
// Center the Y axis label when height changes.
model.when(["yAxisText", "height"], function (yAxisText, height) {
yAxisText.attr("x", -height / 2);
});
// Update Y axis label.
model.when(["yAxisText", "yAxisLabel"], function (yAxisText, yAxisLabel) {
yAxisText.text(yAxisLabel);
});
// Update the Y axis based on the Y scale.
model.when(["yAxisG", "yScale"], function (yAxisG, yScale) {
yAxisG.call(d3.svg.axis().orient("left").scale(yScale));
});
// Adjust Y axis tick mark parameters.
// See https://github.com/mbostock/d3/wiki/Quantitative-Scales#linear_tickFormat
model.when(['yAxisNumTicks', 'yAxisTickFormat'], function (count, format) {
yAxis.ticks(count, format);
});
// Add an SVG group to contain the line.
model.when("g", function (g) {
model.barsG = g.append("g");
});
// Draw the bars.
model.when(["barsG", "sortedData", "getXScaled", "getYScaled", "xScale", "height"],
function (barsG, sortedData, getXScaled, getYScaled, xScale, height){
var bars = barsG.selectAll("rect").data(sortedData);
bars.enter().append("rect");
bars.attr("x", getXScaled).attr("y", getYScaled)
.attr("width", xScale.rangeBand())
.attr("height", function(d) { return height - getYScaled(d); });
bars.exit().remove();
});
// Set defaults at the end so they override optional properties set to None.
model.set(defaults);
return model;
};
});
// This module provides an API layer above the
// Data Canvas - Sense Your City API described at
// http://map.datacanvas.org/#!/data
define(["jquery", "lodash", "async"], function ($, _, async){
// See API documentation at http://map.datacanvas.org/#!/data
var API_URL = "http://sensor-api.localdata.com/api/v1/aggregations.csv",
// List of all cities with available data.
cities = ["San Francisco", "Bangalore", "Boston", "Geneva", "Rio de Janeiro", "Shanghai", "Singapore"],
// The default parameters to pass into the API.
defaultParams = {
// Use averaging as the aggregation operator.
op: "mean",
// Include all available fields.
fields: "temperature,light,airquality_raw,sound,humidity,dust",
// Get data for every 5 minutes.
resolution: "5m",
}
// Fetches the latest data for a given city.
function getLatestDataForCity(city, callback){
// Get data for the last 5 minutes.
// 1000 milliseconds/second, 60 seconds/minute, 5 minutes
var params = _.extend({
from: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
before: new Date().toISOString(),
"over.city": city
}, defaultParams);
// Use jQuery to fetch the data.
// jQuery is used here rather than D3 because of its nice parameter syntax.
$.get(API_URL, params, function(csv) {
// Parse the CSV string.
callback(null, d3.csv.parse(csv, function(d){
// Parse ISO date strings into Date objects.
d.date = new Date(d.timestamp);
// Parse strings into Numbers for numeric fields.
d.temperature = +d.temperature;
d.light = +d.light
d.airquality_raw = +d.airquality_raw
d.sound = +d.sound
d.humidity = +d.humidity
d.dust = +d.dust
return d;
}));
});
};
// Fetches the current temperature across all cities.
return function getLatestData(callback){
async.map(cities, getLatestDataForCity, function(err, results){
callback(err, _.flatten(results));
});
}
});
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!-- Runs the main program found in main.js. -->
<script data-main="main.js" src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.1.14/require.js"></script>
<!-- Configure RequireJS paths for third party libraries. -->
<script>
requirejs.config({
paths: {
d3: "//d3js.org/d3.v3.min",
jquery: "//code.jquery.com/jquery-2.1.1.min",
lodash: "//cdnjs.cloudflare.com/ajax/libs/lodash.js/3.4.0/lodash.min",
async: "//cdnjs.cloudflare.com/ajax/libs/async/0.9.0/async"
}
});
</script>
<!-- Include CSS that styles the visualization. -->
<link rel="stylesheet" href="styles.css">
<title>Bar Chart</title>
</head>
<body>
<!-- The visualization will be injected into this div. -->
<div id="container"></div>
</body>
</html>
// This is the main program that sets up a bar chart to visualize data from the Data Canvas - Sense Your City API.
// Curran Kelleher March 2015
require(["getLatestData", "barChart", "model"], function (getLatestData, BarChart, Model) {
// Initialize the bar chart.
var barChart = BarChart({
// Bar identity.
xAttribute: "city",
xAxisLabel: "City",
// Bar height.
yAttribute: "temperature",
yAxisLabel: "Temperature (°C)",
// Bar ordering.
sortField: "temperature",
sortOrder: "descending",
// Use a fixed value of 0 for the temperature axis.
yDomainMin: 0,
// Spacing between bars.
barPadding: 0.1,
// Tell the chart which DOM element to insert itself into.
container: d3.select("#container").node(),
// Specify the margin and text label offsets.
margin: {
top: 10,
right: 10,
bottom: 60,
left: 70
},
yAxisLabelOffset: 1.4, // Unit is CSS "em"s
xAxisLabelOffset: 1.9,
titleOffset: -0.2
});
// Pass the latest data into the bar chart.
function update(){
getLatestData(function(err, data){
barChart.data = data;
});
}
// Initialize the data.
update();
// Update the data every 5 minutes.
setInterval(update, 1000 * 60 * 5);
// Sets the `box` model property
// based on the size of the container,
function computeBox(){
barChart.box = {
width: container.clientWidth,
height: container.clientHeight
};
}
// once to initialize `model.box`, and
computeBox();
// whenever the browser window resizes in the future.
window.addEventListener("resize", computeBox);
// Output the data flow graph data.
setTimeout(function(){
console.log(JSON.stringify(Model.getFlowGraph()));
}, 1000);
});
// A functional reactive model library.
//
(function(){
// The D3 conventional graph representation.
// See https://github.com/mbostock/d3/wiki/Force-Layout#nodes
var nodes, links, idCounter, map;
function resetFlowGraph(){
nodes = [];
links = [];
idCounter = 0;
map = {};
}
function getFlowGraph(){
return {
nodes: nodes,
links: links
};
}
resetFlowGraph();
// Adds the nodes and links to the data flow graph for one
// particular reactive function.
function updateLambda(modelId, lambdaId, inProperties, outProperties){
var lambda = lambdaNode(lambdaId);
inProperties.forEach(function(property){
link(propertyNode(modelId, property), lambda);
});
outProperties.forEach(function(property){
link(lambda, propertyNode(modelId, property));
});
}
function lambdaNode(id){
return getOrCreate(id, nodes, createLambda);
}
function createLambda(index){
return {
type: "lambda",
index: index
};
}
function propertyNode(modelId, property){
var id = modelId + "." + property;
return getOrCreate(id, nodes, createPropertyNode(property));
}
function createPropertyNode(property){
return function(index){
return {
type: "property",
index: index,
property: property
};
};
}
function link(sourceNode, targetNode){
var source = sourceNode.index,
target = targetNode.index,
id = source + "-" + target;
getOrCreate(id, links, createLink(source, target));
}
function createLink(source, target){
return function(index){
return {
source: source,
target: target
};
};
}
function getOrCreate(id, things, createThing){
var thing = map[id];
if(!thing){
thing = map[id] = createThing(things.length);
things.push(thing);
}
return thing;
}
// The constructor function, accepting default values.
function Model(defaults){
// The returned public API object.
var model = {},
// The internal stored values for tracked properties. { property -> value }
values = {},
// The callback functions for each tracked property. { property -> [callback] }
listeners = {},
// The set of tracked properties. { property -> true }
trackedProperties = {},
modelId = idCounter++,
changedProperties = {};
// The functional reactive "when" operator.
//
// * `properties` An array of property names (can also be a single property string).
// * `callback` A callback function that is called:
// * with property values as arguments, ordered corresponding to the properties array,
// * only if all specified properties have values,
// * once for initialization,
// * whenever one or more specified properties change,
// * on the next tick of the JavaScript event loop after properties change,
// * only once as a result of one or more synchronous changes to dependency properties.
function when(properties, callback, thisArg){
var lambdaId = idCounter++;
// Make sure the default `this` becomes
// the object you called `.on` on.
thisArg = thisArg || this;
// Handle either an array or a single string.
properties = (properties instanceof Array) ? properties : [properties];
// This function will trigger the callback to be invoked.
var triggerCallback = debounce(function (){
var args = properties.map(function(property){
return values[property];
});
if(allAreDefined(args)){
changedProperties = {};
callback.apply(thisArg, args);
updateLambda(modelId, lambdaId, properties, Object.keys(changedProperties));
}
});
// Trigger the callback once for initialization.
triggerCallback();
// Trigger the callback whenever specified properties change.
properties.forEach(function(property){
on(property, triggerCallback);
});
// Return this function so it can be removed later.
return triggerCallback;
}
// Returns a debounced version of the given function.
// See http://underscorejs.org/#debounce
function debounce(callback){
var queued = false;
return function () {
if(!queued){
queued = true;
setTimeout(function () {
queued = false;
callback();
}, 0);
}
};
}
// Returns true if all elements of the given array are defined, false otherwise.
function allAreDefined(arr){
return !arr.some(function (d) {
return typeof d === 'undefined' || d === null;
});
}
// Adds a change listener for a given property with Backbone-like behavior.
// Similar to http://backbonejs.org/#Events-on
function on(property, callback, thisArg){
// Make sure the default `this` becomes
// the object you called `.on` on.
thisArg = thisArg || this;
getListeners(property).push(callback);
track(property, thisArg);
}
// Gets or creates the array of listener functions for a given property.
function getListeners(property){
return listeners[property] || (listeners[property] = []);
}
// Tracks a property if it is not already tracked.
function track(property, thisArg){
if(!(property in trackedProperties)){
trackedProperties[property] = true;
values[property] = model[property];
Object.defineProperty(model, property, {
get: function () { return values[property]; },
set: function(newValue) {
var oldValue = values[property];
values[property] = newValue;
getListeners(property).forEach(function(callback){
callback.call(thisArg, newValue, oldValue);
});
changedProperties[property] = true;
}
});
}
}
// Removes a listener added using `when()`.
function cancel(listener){
for(var property in listeners){
off(property, listener);
}
}
// Removes a change listener added using `on`.
function off(property, callback){
listeners[property] = listeners[property].filter(function (listener) {
return listener !== callback;
});
}
// Sets all of the given values on the model.
// `newValues` is an object { property -> value }.
function set(newValues){
for(var property in newValues){
model[property] = newValues[property];
}
}
// Transfer defaults passed into the constructor to the model.
set(defaults);
// Expose the public API.
model.when = when;
model.cancel = cancel;
model.on = on;
model.off = off;
model.set = set;
return model;
}
Model.getFlowGraph = getFlowGraph;
Model.resetFlowGraph = resetFlowGraph;
// Support AMD (RequireJS), CommonJS (Node), and browser globals.
// Inspired by https://github.com/umdjs/umd
if (typeof define === "function" && define.amd) {
define([], function () { return Model; });
} else if (typeof exports === "object") {
module.exports = Model;
} else {
this.Model = Model;
}
})();
/* Make the visualization container fill the page. */
#container {
position: fixed;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
}
/* Style the visualization. Draws from http://bl.ocks.org/mbostock/3887118 */
/* Tick mark labels */
.axis .tick text {
font: 12pt sans-serif;
}
/* Axis labels */
.axis text {
font: 20pt sans-serif;
}
.axis path,
.axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.line {
fill: none;
stroke: black;
stroke-width: 1.5px;
}
.title-text {
text-anchor: middle;
font: 30pt sans-serif;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment