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