Skip to content

Instantly share code, notes, and snippets.

@curran
Last active August 29, 2015 14:17
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 curran/a274af1f64dff25ceca1 to your computer and use it in GitHub Desktop.
Save curran/a274af1f64dff25ceca1 to your computer and use it in GitHub Desktop.
Data Canvas Part 7 - Scatter Lines

This program makes a scatter plot (with lines) and line chart from data in the Data Canvas - Sense Your City API and makes use of the Chiasm visualization runtime engine.

The line chart shows the temperature for all cities with available data. The program constantly fetches more data going back in time, in invervals of 24 hours with a resolution of 5 minutes.

Draws from

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<!--
A data visualization editor.
Curran Kelleher March 2015
-->
<title>Visualization Editor</title>
<link rel="stylesheet" href="//curran.github.io/cdn/inlet/inlet.css">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/codemirror/5.0.0/codemirror.css">
<link rel="stylesheet" href="styles.css">
<style>
</style>
</head>
<body>
<!-- The container for the runtime environment. -->
<div id="container"></div>
<!-- The gear icon in the upper right. -->
<!-- Image from http://simpleicon.com/gear-12.html -->
<img id="gear" src="gear.png">
<!-- Use RequireJS for module loading. -->
<script src="//cdnjs.cloudflare.com/ajax/libs/require.js/2.1.16/require.min.js"></script>
<!-- Configure AMD modules. -->
<script src="requireJSConfig.js"></script>
<!-- Run the main program. -->
<script src="main.js"></script>
</body>
</html>
// This program loads the configuration file called "visConfig.json".
require(["d3", "model", "chiasm/runtime", "./senseYourCityData", "./scatterPlotWithLines"],
function (d3, Model, Runtime, senseYourCityData, scatterPlotWithLines) {
// Instantiate the Chiasm runtime within the container.
var runtime = Runtime(document.getElementById("container"));
// Attach the data API plugin to the runtime.
runtime.plugins.senseYourCityData = senseYourCityData;
// Attach the customized scatter plot to the runtime as a plugin.
runtime.plugins.scatterPlotWithLines = scatterPlotWithLines;
// Load the visualization configuration.
d3.json("visConfig.json", function (err, config) {
runtime.config = config;
});
// Toggle visibility of the configuration editor.
d3.select("#gear").on("click", function(){
runtime.getComponent("editor", function(editor){
if(editor.size != "0px"){
editor.size = "0px";
} else {
editor.size = "325px";
}
});
});
});
// This is the RequireJS configuration that sets up module paths.
//
// This file is documented here:
// http://requirejs.org/docs/api.html#config
//
// Curran Kelleher March 2015
(function(){
// Use a fixed version of Chiasm, which provides the visualization runtime.
var chiasmPath = "//curran.github.io/cdn/chiasm-v0.1.4/client/src";
// Here's how to can use a local development version
// if this Gist is cloned into a sibling directory to the chiasm repo.
//var chiasmPath = "../../chiasm/client/src";
requirejs.config({
// Set up the Chiasm package.
// https://github.com/curran/chiasm
packages: [{
name: "chiasm",
location: chiasmPath + "/core"
}],
// Set up paths for Bower dependencies.
// Uses github.com/curran/cdn
paths: {
// AJAX library.
// http://jquery.com/
jquery: "//code.jquery.com/jquery-2.1.1.min",
// Visualization library.
// http://d3js.org/
d3: "//curran.github.io/cdn/d3-v3.5.5/d3.min",
// Reactive model library.
// https://github.com/curran/model
model: "//curran.github.io/cdn/model-v0.2.0/dist/model",
// Functional programming utilities.
// http://benmccormick.org/2014/11/12/underscore-vs-lodash/
lodash: "//cdnjs.cloudflare.com/ajax/libs/lodash.js/3.5.0/lodash.min",
// Asynchronous control flow.
// https://github.com/caolan/async
async: "//cdnjs.cloudflare.com/ajax/libs/async/0.9.0/async",
// Syntax-highlighted text editor for code.
// http://codemirror.net/
codemirror: "//curran.github.io/cdn/codemirror-v5.0.0",
// Provides interactive color picker and slider for CodeMirror.
// http://github.com/enjalot/Inlet.git
inlet: "//curran.github.io/cdn/inlet/inlet",
// Provides miltidimensional filtering.
// http://square.github.io/crossfilter/
crossfilter: "//cdnjs.cloudflare.com/ajax/libs/crossfilter/1.3.11/crossfilter.min",
// Configure paths for plugins loaded at runtime.
plugins: chiasmPath + "/plugins"
}
});
})();
// A reusable scatter plot module.
// Curran Kelleher March 2015
define(["d3", "model"], 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 ScatterPlot(runtime) {
// Create a Model instance for the visualization.
// This will serve as its public API.
var model = Model({
container: runtime.div
});
// Create an SVG element from the container DOM element.
model.when("container", function (container) {
model.svg = d3.select(container).append("svg")
// Use CSS `position: absolute;`
// so setting `left` and `top` later will
// position the SVG relative to the container div.
.style("position", "absolute");
});
// Adjust the size of the SVG based on the `box` property.
model.when(["svg", "box"], function (svg, box) {
// Set the CSS `left` and `top` properties
// to move the SVG to `(box.x, box.y)`
// relative to the container div.
svg
.style("left", box.x + "px")
.style("top", box.y + "px")
.attr("width", box.width)
.attr("height", box.height);
});
// Create an SVG group that will contain the visualization.
model.when("svg", function (svg) {
model.g = svg.append("g");
});
model.when("g", function (g) {
// Add an SVG group to contain the marks.
model.circlesG = g.append("g");
// Create a group for the brush.
model.brushG = g.append("g").attr("class", "brush");
// The circles group is added first, before the brush group,
// so that mouse events go to the brush rather than to the
// circles, even when the mouse is on top of a circle.
});
// Disable brushing by default.
model.brushEnabled = false;
// Set up brushing interactions to define `brushedIntervals` on the model.
model.when(["brushEnabled", "xColumn", "yColumn", "xScale", "yScale"],
function (brushEnabled, xColumn, yColumn, xScale, yScale) {
if(brushEnabled){
var brush = d3.svg.brush();
brush.on("brush", function () {
model.brushedIntervals = brushToIntervals(brush, xColumn, yColumn, xScale, yScale);
});
model.brush = brush;
}
});
function brushToIntervals(brush, xColumn, yColumn, xScale, yScale){
var brushedIntervals = {};
if(!brush.empty()
&& brush.extent() !== null){
var e = brush.extent(),
xMin = e[0][0],
yMin = e[0][1],
xMax = e[1][0],
yMax = e[1][1],
epsilon = 0.01;
// Account for the edge case where the brush is at the
// X or Y min or max. Adding a small value ensures that all
// points are included when crossfilter's filterRange is used,
// because filterRange provides an exclusive range, not inclusive.
// See https://github.com/square/crossfilter/wiki/API-Reference#dimension_filterRange
if(xMax === xScale.domain()[1]){ xMax += epsilon; }
if(yMax === yScale.domain()[1]){ yMax += epsilon; }
if(xMin === xScale.domain()[0]){ xMin -= epsilon; }
if(yMin === yScale.domain()[0]){ yMin -= epsilon; }
brushedIntervals[xColumn] = [xMin, xMax];
brushedIntervals[yColumn] = [yMin, yMax];
} else {
brushedIntervals[xColumn] = [None, None];
brushedIntervals[yColumn] = [None, None];
}
return brushedIntervals;
}
function intervalsToBrush(brushedIntervals, xColumn, yColumn){
return [
[brushedIntervals[xColumn][0], brushedIntervals[yColumn][0]],
[brushedIntervals[xColumn][1], brushedIntervals[yColumn][1]]
];
}
// Update the rendered brush.
model.when(["brushedIntervals", "brush", "brushG", "xColumn", "yColumn", "xScale", "yScale"],
function (brushedIntervals, brush, brushG, xColumn, yColumn, xScale, yScale) {
// Update the scales within the brush.
brush.x(xScale);
brush.y(yScale);
// Update the extent of the brush.
brush.extent(intervalsToBrush(brushedIntervals, xColumn, yColumn));
// Render the brush onto the brush group.
brushG.call(brush);
});
// Adjust the SVG group translation 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", "xColumn"], function (data, xColumn) {
model.getX = function (d) { return d[xColumn]; };
});
// Compute the domain of the X attribute.
// Allow the API client to optionally specify fixed min and max values.
model.xDomainMin = None;
model.xDomainMax = None;
model.when(["data", "getX", "xDomainMin", "xDomainMax"],
function (data, getX, xDomainMin, xDomainMax) {
if(xDomainMin === None && xDomainMax === None){
model.xDomain = d3.extent(data, getX);
} else {
if(xDomainMin === None){
xDomainMin = d3.min(data, getX);
}
if(xDomainMax === None){
xDomainMax = d3.max(data, getX);
}
model.xDomain = [xDomainMin, xDomainMax]
}
});
// Compute the X scale.
model.when(["xDomain", "width"], function (xDomain, width) {
model.xScale = d3.scale.linear().domain(xDomain).range([0, width]);
});
// 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", "yColumn"], function (data, yColumn) {
model.getY = function (d) { return d[yColumn]; };
});
// 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));
});
// Allow the API client to optionally specify a size column.
model.sizeColumn = None;
// The default radius of circles in pixels.
model.sizeDefault = 2;
// The min and max circle radius in pixels.
model.sizeMin = 0.5;
model.sizeMax = 6;
// Set up the size scale.
model.when(["sizeColumn", "data", "sizeDefault", "sizeMin", "sizeMax"],
function (sizeColumn, data, sizeDefault, sizeMin, sizeMax){
if(sizeColumn !== None){
var getSize = function (d){ return d[sizeColumn] },
sizeScale = d3.scale.linear()
.domain(d3.extent(data, getSize))
.range([sizeMin, sizeMax]);
model.getSizeScaled = function (d){ return sizeScale(getSize(d)); };
} else {
model.getSizeScaled = function (d){ return sizeDefault; };
}
});
// Allow the API client to optionally specify a color column.
model.colorColumn = None;
model.colorRange = None;
// The default color of circles (CSS color string).
model.colorDefault = "black";
// Set up the size scale.
model.when(["colorColumn", "data", "colorDefault", "colorRange"],
function (colorColumn, data, colorDefault, colorRange){
if(colorColumn !== None && colorRange !== None){
var getColor = function (d){ return d[colorColumn] },
colorScale = d3.scale.ordinal()
.domain(data.map(getColor))
.range(colorRange);
model.colorScale = colorScale;
model.getColorScaled = function (d){ return colorScale(getColor(d)); };
} else {
model.colorScale = model.getColorScaled = function (d){ return colorDefault; };
}
});
// Filter out points that go beyond the edges of the plot
// for the case that the domain is set explicitly and is
// smaller than the extent of the data.
model.when(["data", "getX", "getY", "xScale", "yScale"],
function(data, getX, getY, xScale, yScale){
var xMin = xScale.domain()[0], xMax = xScale.domain()[1],
yMin = yScale.domain()[0], yMax = yScale.domain()[1];
model.visibleData = data.filter(function(d){
var x = getX(d), y = getY(d);
return x > xMin && x < xMax && y > yMin && y < yMax;
});
});
// Draw the circles of the scatter plot.
//model.when(["visibleData", "circlesG", "getXScaled", "getYScaled", "getSizeScaled", "getColorScaled"],
// function (visibleData, circlesG, getXScaled, getYScaled, getSizeScaled, getColorScaled){
// var circles = circlesG.selectAll("circle").data(visibleData);
// circles.enter().append("circle");
// circles
// .attr("cx", getXScaled)
// .attr("cy", getYScaled)
// .attr("r", getSizeScaled)
// .attr("fill", getColorScaled);
// circles.exit().remove();
//});
// Add an SVG group to contain the lines.
model.when("g", function (g) {
model.lineG = g.append("g");
});
// Draw the lines.
model.lineColumn = None;
model.when(["lineG", "data", "lineColumn", "getXScaled", "getYScaled", "colorScale"],
function (lineG, data, lineColumn, getXScaled, getYScaled, colorScale){
var linesData = d3.nest()
.key(function(d){
if(lineColumn !== None){
return d[lineColumn]; // Have multiple lines.
} else {
return "X";// have only a single line.
}
})
.entries(data),
line = d3.svg.line().x(getXScaled).y(getYScaled),
lines = lineG.selectAll(".line").data(linesData);
lines.enter().append("path").attr("class", "line");
lines
.attr("d", function(d){ return line(d.values); })
.style("stroke", function(d){ return colorScale(d.key); });
lines.exit().remove();
});
return model;
};
});
// A Chiasm plugin for loading data from the Data Canvas Sense Your City API.
// http://map.datacanvas.org/#!/data
define(["model", "jquery", "lodash", "async"], function (Model, $, _, async){
return function (runtime) {
var model = Model({
}),
// See API documentation at http://map.datacanvas.org/#!/data
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 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 getDataForCity(city, from, before, callback){
// Get data for the last 24 hours.
// 1000 milliseconds/second, 60 seconds/minute, 5 minutes
var params = _.extend({
from: from,
before: before,
"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.timestamp = 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.
function getData(from, before, callback){
async.map(cities, function(city, callback){
getDataForCity(city, from, before, callback);
}, function(err, results){
callback(err, _.flatten(results));
});
}
// Fetch the data and expose it to the model.
// 24 hours in millioseconds.
var msInterval = 1000 * 60 * 60 * 24,
msTime = Date.now(),
from = new Date(msTime - msInterval).toISOString(),
before = new Date(msTime).toISOString();
getData(from, before, function(err, data){
model.data = data;
from = new Date(msTime - msInterval * 2).toISOString(),
before = new Date(msTime - msInterval).toISOString();
getData(from, before, function(err, data){
model.data = data;
});
});
// Get data for the last intervalCountMax days, adding one day at a time.
var intervalCount = 0,
intervalCountMax = 200;
async.whilst(
function () { return intervalCount < intervalCountMax; },
function (callback) {
from = new Date(msTime - msInterval * (intervalCount + 1)).toISOString(),
before = new Date(msTime - msInterval * intervalCount).toISOString();
getData(from, before, function(err, data){
if(model.data){
model.data = _.sortBy(_.union(model.data, data), "timestamp");
console.log(model.data.length + " records over " + intervalCount + " days");
} else {
model.data = data;
}
callback();
});
intervalCount++;
}
);
return model;
};
});
/* Style the axes and labels for visualizations.
Curran Kelleher March 2015 */
/* Make the container fill the page. */
body {
background-color: black;
}
#container {
position: fixed;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
}
#gear {
position: fixed;
right: 0px;
top: 0px;
background-color: gray;
border-radius: 20px;
}
#gear:hover {
background-color: lightgray;
}
#gear:active {
background-color: gray;
}
/* Tick mark labels */
.axis .tick text {
font: 8pt sans-serif;
fill: white;
}
/* Axis labels */
.axis text {
font: 14pt sans-serif;
fill: white;
}
/* Lines within axes. */
.axis path,
.axis line {
fill: none;
stroke: gray;
shape-rendering: crispEdges;
}
/* Lines within the line chart. */
.line {
fill: none;
stroke-width: 1px;
}
/* Style the title text at the top of the visualization. */
.title-text {
text-anchor: middle;
font: 20pt sans-serif;
fill: white;
}
.hover-line {
stroke: white;
stroke-width: 2px;
}
{
"layout": {
"plugin": "layout",
"state": {
"layout": {
"orientation": "horizontal",
"children": [
"editor",
{
"orientation": "vertical",
"children": [
"scatterPlot",
"lineChart"
]
}
]
}
}
},
"editor": {
"plugin": "configEditor",
"state": {
"size": "0px"
}
},
"color": {
"plugin": "colorScale",
"state": {
"domain": [
"San Francisco",
"Bangalore",
"Boston",
"Geneva",
"Rio de Janeiro",
"Shanghai",
"Singapore"
],
"range": [
"#927400",
"#cc0006",
"#db008e",
"#8a3dff",
"#006aff",
"#00979b",
"#009100"
]
}
},
"scatterPlot": {
"plugin": "scatterPlotWithLines",
"state": {
"colorColumn": "city",
"lineColumn": "city",
"xColumn": "humidity",
"xAxisLabel": "Humidity",
"yColumn": "temperature",
"yAxisLabel": "Temperature (°C)",
"margin": {
"top": 32,
"right": 2,
"bottom": 45,
"left": 47
},
"xAxisLabelOffset": 2.128,
"yAxisLabelOffset": 1.4,
"colorDefault": "#059e00",
"title": "Temperature by Humidity",
"titleOffset": -0.1617408,
"size": 4.24
}
},
"lineChart": {
"plugin": "lineChart",
"state": {
"lineColumn": "city",
"colorColumn": "city",
"xColumn": "timestamp",
"xAxisLabel": "Time",
"yColumn": "temperature",
"margin": {
"top": 8,
"right": 2,
"bottom": 40,
"left": 47
},
"xAxisLabelOffset": 1.9,
"yAxisLabelOffset": 1.4
}
},
"senseYourCityData": {
"plugin": "senseYourCityData"
},
"crossfilter": {
"plugin": "crossfilter",
"state": {
"dimensions": [
"city",
"timestamp"
]
}
},
"links": {
"plugin": "links",
"state": {
"bindings": [
"senseYourCityData.data -> lineChart.data",
"senseYourCityData.data -> scatterPlot.data",
"color.domain -> lineChart.colorDomain",
"color.range -> lineChart.colorRange",
"color.domain -> scatterPlot.colorDomain",
"color.range -> scatterPlot.colorRange"
]
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment