// 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(defaults) { // Create a Model instance for the visualization. // This will serve as its public API. var model = Model(); // 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.getColorScaled = function (d){ return colorScale(getColor(d)); }; } else { 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(); }); // Set defaults at the end so they override optional properties set to None. model.set(defaults); return model; }; });