UNPKG

dimple-js

Version:

Dimple is an object-oriented API allowing you to create flexible axis-based charts using [d3.js](http://d3js.org "d3.js").

389 lines (361 loc) 21.8 kB
// Copyright: 2015 AlignAlytics // License: "https://github.com/PMSI-AlignAlytics/dimple/blob/master/MIT-LICENSE.txt" // Source: /src/objects/plot/area.js dimple.plot.area = { // By default the values are stacked stacked: true, // This is a grouped plot meaning many points are treated as one series value grouped: true, // The axis positions affecting the area series supportedAxes: ["x", "y", "c"], // Draw the axis draw: function (chart, series, duration) { // Get the position data var data = series._positionData, areaData = [], theseShapes = null, className = "dimple-series-" + chart.series.indexOf(series), firstAgg = (series.x._hasCategories() || series.y._hasCategories() ? 0 : 1), interpolation, graded = false, i, j, k, key, keyString, rowIndex, updated, removed, orderedSeriesArray, catPoints = {}, allPoints = [], points, finalPointArray = [], basePoints, basePoint, cat, catVal, group, p, b, l, lastAngle, catCoord, valCoord, onEnter = function () { return function (e, shape, chart, series) { d3.select(shape).style("opacity", 1); dimple._showPointTooltip(e, shape, chart, series); }; }, onLeave = function (lineData) { return function (e, shape, chart, series) { d3.select(shape).style("opacity", (series.lineMarkers || lineData.data.length < 2 ? dimple._helpers.opacity(e, chart, series) : 0)); dimple._removeTooltip(e, shape, chart, series); }; }, drawMarkers = function (d, context) { dimple._drawMarkers(d, chart, series, duration, className, graded, onEnter(d), onLeave(d), context); }, coord = function (position, datum) { var val; if (series.interpolation === "step" && series[position]._hasCategories()) { val = dimple._helpers[position](datum, chart, series) + (position === "y" ? dimple._helpers.height(datum, chart, series) : 0); if (series[position].categoryFields.length < 2) { val += (position === "y" ? 1 : -1) * dimple._helpers[position + "Gap"](chart, series); } } else { val = dimple._helpers["c" + position](datum, chart, series); } // Remove long decimals from the coordinates as this fills the dom up with noise and makes matching below less likely to work. It // shouldn't really matter but positioning to < 0.1 pixel is pretty pointless anyway. UPDATE: Turns out it isn't, see Issue #79. points > pixels // causes multiple points to fall on the same co-ordinate which results in drawing problems. return parseFloat(val); }, getArea = function (inter, originProperty) { return d3.svg.line() .x(function (d) { return (series.x._hasCategories() || !originProperty ? d.x : series.x[originProperty]); }) .y(function (d) { return (series.y._hasCategories() || !originProperty ? d.y : series.y[originProperty]); }) .interpolate(inter); }, sortByVal = function (a, b) { return parseFloat(a) - parseFloat(b); }, sortByX = function (a, b) { return parseFloat(a.x) - parseFloat(b.x); }, addNextPoint = function (source, target, startAngle) { // Given a point we need to find the next point clockwise from the start angle var i, point = target[target.length - 1], thisAngle, bestAngleSoFar = 9999, returnPoint = point; for (i = 0; i < source.length; i += 1) { if (source[i].x !== point.x || source[i].y !== point.y) { // get the angle in degrees since start angle thisAngle = 180 - (Math.atan2(source[i].x - point.x, source[i].y - point.y) * (180 / Math.PI)); if (thisAngle > startAngle && thisAngle < bestAngleSoFar) { returnPoint = source[i]; bestAngleSoFar = thisAngle; } } } target.push(returnPoint); return bestAngleSoFar; }; // Handle the special interpolation handling for step interpolation = (series.interpolation === "step" ? "step-after" : series.interpolation); // Get the array of ordered values orderedSeriesArray = dimple._getSeriesOrder(series.data || chart.data, series); if (series.c && ((series.x._hasCategories() && series.y._hasMeasure()) || (series.y._hasCategories() && series.x._hasMeasure()))) { graded = true; } // If there is a coordinate with categories get it here so we don't have to keep checking if (series.x._hasCategories()) { catCoord = "x"; valCoord = "y"; } else if (series.y._hasCategories()) { catCoord = "y"; valCoord = "x"; } // Create a set of area data grouped by the aggregation field for (i = 0; i < data.length; i += 1) { key = []; rowIndex = -1; // Skip the first category unless there is a category axis on x or y for (k = firstAgg; k < data[i].aggField.length; k += 1) { key.push(data[i].aggField[k]); } // Find the corresponding row in the areaData keyString = dimple._createClass(key); for (k = 0; k < areaData.length; k += 1) { if (areaData[k].keyString === keyString) { rowIndex = k; break; } } // Add a row to the area data if none was found if (rowIndex === -1) { rowIndex = areaData.length; areaData.push({ key: key, keyString: keyString, color: "white", data: [], points: [], area: {}, entry: {}, exit: {}, // Group refers to groupings along the category axis. If there are groupings it will be recorded, otherwise all is used as a default group: (catCoord && data[i][catCoord + "Field"] && data[i][catCoord + "Field"].length >= 2 ? data[i][catCoord + "Field"][0] : "All") }); } // Add this row to the relevant data areaData[rowIndex].data.push(data[i]); } // Sort the area data itself based on the order series array - this matters for stacked areas and default color // consistency with colors usually awarded in terms of prominence if (orderedSeriesArray) { areaData.sort(function (a, b) { return dimple._arrayIndexCompare(orderedSeriesArray, a.key, b.key); }); } // Create a set of area data grouped by the aggregation field for (i = 0; i < areaData.length; i += 1) { // Sort the points so that areas are connected in the correct order areaData[i].data.sort(dimple._getSeriesSortPredicate(chart, series, orderedSeriesArray)); // Get points here, this is so that as well as drawing the line with them, we can also // use them for the baseline for (j = 0; j < areaData[i].data.length; j += 1) { areaData[i].points.push({ x: coord("x", areaData[i].data[j]), y: coord("y", areaData[i].data[j]) }); // if there is a category axis, add the points to a distinct set. Set these to use the origin value // this will be updated with the last value in each case as we build the areas if (catCoord) { if (!catPoints[areaData[i].group]) { catPoints[areaData[i].group] = {}; } catPoints[areaData[i].group][areaData[i].points[areaData[i].points.length - 1][catCoord]] = series[valCoord]._origin; } } points = areaData[i].points; // If this is a step interpolation we need to add in some extra points to the category axis // This is a little tricky but we need to add a new point duplicating the last category value. In order // to place the point we need to calculate the gap between the last x and the penultimate x and apply that // gap again. if (series.interpolation === "step" && points.length > 1 && catCoord) { if (series.x._hasCategories()) { points.push({ x : 2 * points[points.length - 1].x - points[points.length - 2].x, y : points[points.length - 1].y }); catPoints[areaData[i].group][points[points.length - 1][catCoord]] = series[valCoord]._origin; } else if (series.y._hasCategories()) { points = [{ x : points[0].x, y : 2 * points[0].y - points[1].y }].concat(points); catPoints[areaData[i].group][points[0][catCoord]] = series[valCoord]._origin; // The prepend above breaks the reference so it needs to be reapplied here. areaData[i].points = points; } } } // catPoints needs to be lookup, but also accessed sequentially so we need to create an array of keys for (cat in catPoints) { if (catPoints.hasOwnProperty(cat)) { allPoints[cat] = []; for (catVal in catPoints[cat]) { if (catPoints[cat].hasOwnProperty(catVal)) { allPoints[cat].push(parseFloat(catVal)); } } // Sort the points as integers allPoints[cat].sort(sortByVal); } } // Create the areas for (i = 0; i < areaData.length; i += 1) { points = areaData[i].points; group = areaData[i].group; basePoints = []; finalPointArray = []; // If this should have colour gradients, add them if (graded) { dimple._addGradient(areaData[i].key, "fill-area-gradient-" + areaData[i].keyString, (series.x._hasCategories() ? series.x : series.y), data, chart, duration, "fill"); } // All points will only be populated if there is a category axis if (allPoints[group] && allPoints[group].length > 0) { // Iterate the point array because we need to fill in zero points for missing ones, otherwise the areas // will cross where an upper area has no value and a lower value has a spike Issue #7 for (j = 0, k = 0; j < allPoints[group].length; j += 1) { // We are only interested in points between the first and last point of this areas data (i.e. don't fill ends - important // for grouped area charts). We have to use a strange criteria here. If there are no group gaps on a grouped area // chart the end point of one series will clash with the start point of another, therefore we have to ignore fill-in's within // a couple of pixels of the start and end points if (allPoints[group][j] >= points[0][catCoord] && allPoints[group][j] <= points[points.length - 1][catCoord]) { // Get a base point, this needs to go on the base points array as well as filling in gaps in the point array. // Create a point using the coordinate on the category axis and the last recorded value // position from the dictionary basePoint = {}; basePoint[catCoord] = allPoints[group][j]; basePoint[valCoord] = catPoints[group][allPoints[group][j]]; // add the base point basePoints.push(basePoint); // handle missing points if (points[k][catCoord] > allPoints[group][j]) { // If there is a missing point we need to in fill finalPointArray.push(basePoint); } else { // They must be the same finalPointArray.push(points[k]); // Use this to update the dictionary to the new value coordinate catPoints[areaData[i].group][allPoints[group][j]] = points[k][valCoord]; k += 1; } } } } else { // If there is no category axis we need to apply some custom logic. In order to avoid // really jagged areas the default behaviour will be to draw from the left most point then rotate a line // clockwise until it hits another point and continue from each point until back to where we started. This // means it will not connect every point, but it will contain every point: // E.g. // D // C // A B E // F G // H // // Would draw A -> C -> D -> E -> G -> H -> A // // This may not be what everyone wants so if there is a series order specified we will just join // the points in that order instead. This will not allow users to skip points and therefore not achieve // the default behaviour explicitly. if (series._orderRules && series._orderRules.length > 0) { finalPointArray = points.concat(points[0]); } else { // Find the leftmost point points = points.sort(sortByX); finalPointArray.push(points[0]); lastAngle = 0; // Iterate until the first and last points match do { lastAngle = addNextPoint(points, finalPointArray, lastAngle); } while (finalPointArray.length <= points.length && (finalPointArray[0].x !== finalPointArray[finalPointArray.length - 1].x || finalPointArray[0].y !== finalPointArray[finalPointArray.length - 1].y)); } } // Reverse the base points so that they are in the correct order for the path basePoints = basePoints.reverse(); // Get the points that this area will appear p = getArea(interpolation, "_previousOrigin")(finalPointArray); b = getArea((interpolation === "step-after" ? "step-before" : (interpolation === "step-before" ? "step-after" : interpolation)), "_previousOrigin")(basePoints); l = getArea("linear", "_previousOrigin")(finalPointArray); areaData[i].entry = p + (b && b.length > 0 ? "L" + b.substring(1) : "") + (l && l.length > 0 ? "L" + l.substring(1, l.indexOf("L")) : 0); p = getArea(interpolation)(finalPointArray); b = getArea(interpolation === "step-after" ? "step-before" : (interpolation === "step-before" ? "step-after" : interpolation))(basePoints); l = getArea("linear")(finalPointArray); areaData[i].update = p + (b && b.length > 0 ? "L" + b.substring(1) : "") + (l && l.length > 0 ? "L" + l.substring(1, l.indexOf("L")) : 0); p = getArea(interpolation, "_origin")(finalPointArray); b = getArea((interpolation === "step-after" ? "step-before" : (interpolation === "step-before" ? "step-after" : interpolation)), "_origin")(basePoints); l = getArea("linear", "_origin")(finalPointArray); areaData[i].exit = p + (b && b.length > 0 ? "L" + b.substring(1) : "") + (l && l.length > 0 ? "L" + l.substring(1, l.indexOf("L")) : 0); // Add the color in this loop, it can't be done during initialisation of the row because // the areas should be ordered first (to ensure standard distribution of colors areaData[i].color = chart.getColor(areaData[i].key.length > 0 ? areaData[i].key[areaData[i].key.length - 1] : "All"); areaData[i].css = chart.getClass(areaData[i].key.length > 0 ? areaData[i].key[areaData[i].key.length - 1] : "All"); } if (chart._tooltipGroup !== null && chart._tooltipGroup !== undefined) { chart._tooltipGroup.remove(); } if (series.shapes === null || series.shapes === undefined) { theseShapes = chart._group.selectAll("." + className).data(areaData); } else { theseShapes = series.shapes.data(areaData, function (d) { return d.key; }); } // Add theseShapes .enter() .append("path") .attr("id", function (d) { return dimple._createClass([d.key]); }) .attr("class", function (d) { return className + " dimple-line " + d.keyString + " " + chart.customClassList.areaSeries + " " + d.css; }) .attr("d", function (d) { return d.entry; }) .call(function () { // Apply formats optionally if (!chart.noFormats) { this.attr("opacity", function (d) { return (graded ? 1 : d.color.opacity); }) .style("fill", function (d) { return (graded ? "url(#fill-area-gradient-" + d.keyString + ")" : d.color.fill); }) .style("stroke", function (d) { return (graded ? "url(#stroke-area-gradient-" + d.keyString + ")" : d.color.stroke); }) .style("stroke-width", series.lineWeight); } }) .each(function (d) { // Pass line data to markers d.markerData = d.data; drawMarkers(d, this); }); // Update updated = chart._handleTransition(theseShapes, duration, chart) .attr("d", function (d) { return d.update; }) .each(function (d) { // Pass line data to markers d.markerData = d.data; drawMarkers(d, this); }); // Remove removed = chart._handleTransition(theseShapes.exit(), duration, chart) .attr("d", function (d) { return d.exit; }) .each(function (d) { // Using all data for the markers fails because there are no exits in the markers // only the whole line, therefore we need to clear the points here d.markerData = []; drawMarkers(d, this); }); dimple._postDrawHandling(series, updated, removed, duration); // Save the shapes to the series array series.shapes = theseShapes; } };