block by curran 015d34d6d3d562877e51

Data Canvas Part 3 - Bar Chart

Full Screen

This program makes a bar chart from data in the Data Canvas - Sense Your City API.

This shows the temperature for all cities with available data. The data is up to date, and updates every 5 minutes.

Based on Data Canvas Part 2 - Line Chart

The reactive flow of the bar chart. The diagram was constructed using the reactive flow diagram renderer.

index.html

<!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>

barChart.js

// 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;
  };
});

getLatestData.js

// 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));
    });
  }
});

main.js

// 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);
});

model.js

// 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;
  }
})();

styles.css

/* 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;
}