diff --git a/GruntFile.js b/GruntFile.js index 3a979b1ef1..20681ad62d 100644 --- a/GruntFile.js +++ b/GruntFile.js @@ -139,7 +139,6 @@ module.exports = function(grunt) { 'src/outro.js', //Files we don't want to test. 'src/models/lineWith*', - 'src/models/parallelCoordinates*', 'src/models/multiBarTime*', 'src/models/indented*', 'src/models/linePlus*', diff --git a/examples/parallelCoordinatesOrdinal.html b/examples/parallelCoordinatesOrdinal.html new file mode 100644 index 0000000000..ac0aa4f840 --- /dev/null +++ b/examples/parallelCoordinatesOrdinal.html @@ -0,0 +1,86 @@ + + + + + + + + + + + + +
+ +
+ + + + diff --git a/src/models/parallelCoordinates.js b/src/models/parallelCoordinates.js index 10e11b20a7..5f640c1594 100644 --- a/src/models/parallelCoordinates.js +++ b/src/models/parallelCoordinates.js @@ -25,6 +25,8 @@ nv.models.parallelCoordinates = function() { , dragging = [] , axisWithUndefinedValues = [] , lineTension = 1 + , dispatch = d3.dispatch('brush', 'elementMouseover', 'elementMouseout', 'renderEnd') + , enumerateNonNumericDimensions = false , foreground , background , dimensions @@ -43,6 +45,12 @@ nv.models.parallelCoordinates = function() { renderWatch.reset(); selection.each(function(data) { var container = d3.select(this); + + // Watch a transition purely for the purposes of notifying on render complete. + container.watchTransition(renderWatch, 'nv-parallelCoordinates'); + + var availableWidth = nv.utils.availableWidth(width, container, margin), + availableHeight = nv.utils.availableHeight(height, container, margin); availableWidth = nv.utils.availableWidth(width, container, margin); availableHeight = nv.utils.availableHeight(height, container, margin); @@ -72,51 +80,68 @@ nv.models.parallelCoordinates = function() { x.rangePoints([0, availableWidth], 1).domain(enabledDimensions.map(function (d) { return d.key; })); //Set as true if all values on an axis are missing. + var onlyNanValues = {}; + var dimensionTypes = {}; // Extract the list of dimensions and create a scale for each. var oldDomainMaxValue = {}; var displayMissingValuesline = false; var currentTicks = []; dimensionNames.forEach(function(d) { - var extent = d3.extent(dataValues, function (p) { return +p[d]; }); - var min = extent[0]; - var max = extent[1]; - var onlyUndefinedValues = false; - //If there is no values to display on an axis, set the extent to 0 - if (isNaN(min) || isNaN(max)) { - onlyUndefinedValues = true; - min = 0; - max = 0; + // First assume that the dimension is numeric and try to get + // the extent of it. + var extent = d3.extent(data, function(p) { return Number(p[d]); }); + onlyNanValues[d] = false; + + // The user can elect to enumerate each unique value for non + // numeric dimensions, rather than defining an extent of 0. + if (extent[0] === undefined && enumerateNonNumericDimensions) { + // Record this dimension type as being an enumeration. + dimensionTypes[d] = "enum"; + + // Create an ordinal scale rather than a linear one. Treat + // empty strings as undefined. + y[d] = d3.scale.ordinal() + .domain(dataValues.map(function(o) { return String(o[d]); }).filter(function(str) { return str !== ""; }).sort()) + .rangePoints([0, (availableHeight - 12) * 0.9]); } - //Scale axis if there is only one value - if (min === max) { - min = min - 1; - max = max + 1; - } - var f = filters.filter(function (k) { return k.dimension == d; }); - if (f.length !== 0) { - //If there is only NaN values, keep the existing domain. - if (onlyUndefinedValues) { - min = y[d].domain()[0]; - max = y[d].domain()[1]; + else { + dimensionTypes[d] = "number"; + var extent = d3.extent(dataValues, function (p) { return +p[d]; }); + var min = extent[0]; + var max = extent[1]; + + //Scale axis if there is only one value + if (min === max) { + min = min - 1; + max = max + 1; } + var f = filters.filter(function (k) { return k.dimension == d; }); + if (f.length !== 0) { + //If there is only NaN values, keep the existing domain. + if (onlyUndefinedValues) { + min = y[d].domain()[0]; + max = y[d].domain()[1]; + } //If the brush extent is > max (< min), keep the extent value. - else if (!f[0].hasOnlyNaN && displayBrush) { - min = min > f[0].extent[0] ? f[0].extent[0] : min; - max = max < f[0].extent[1] ? f[0].extent[1] : max; - } + else if (!f[0].hasOnlyNaN && displayBrush) { + min = min > f[0].extent[0] ? f[0].extent[0] : min; + max = max < f[0].extent[1] ? f[0].extent[1] : max; + } //If there is NaN values brushed be sure the brush extent is on the domain. - else if (f[0].hasNaN) { - max = max < f[0].extent[1] ? f[0].extent[1] : max; - oldDomainMaxValue[d] = y[d].domain()[1]; - displayMissingValuesline = true; + else if (f[0].hasNaN) { + max = max < f[0].extent[1] ? f[0].extent[1] : max; + oldDomainMaxValue[d] = y[d].domain()[1]; + displayMissingValuesline = true; + } } + + //Use 90% of (availableHeight - 12) for the axis range, 12 reprensenting the space necessary to display "undefined values" text. + //The remaining 10% are used to display the missingValue line. + y[d] = d3.scale.linear() + .domain([min, max]) + .range([(availableHeight - 12) * 0.9, 0]); } - //Use 90% of (availableHeight - 12) for the axis range, 12 reprensenting the space necessary to display "undefined values" text. - //The remaining 10% are used to display the missingValue line. - y[d] = d3.scale.linear() - .domain([min, max]) - .range([(availableHeight - 12) * 0.9, 0]); axisWithUndefinedValues = []; y[d].brush = d3.svg.brush().y(y[d]).on('brushstart', brushstart).on('brush', brush).on('brushend', brushend); @@ -241,17 +266,22 @@ nv.models.parallelCoordinates = function() { restoreBrush(displayBrush); var actives = dimensionNames.filter(function (p) { return !y[p].brush.empty(); }), - extents = actives.map(function (p) { return y[p].brush.extent(); }); + extents = actives.map(function (p) { return y[p].brush.extent(); }); var formerActive = active.slice(0); //Restore active values active = []; foreground.style("display", function (d) { var isActive = actives.every(function (p, i) { - if ((isNaN(d.values[p]) || isNaN(parseFloat(d.values[p]))) && extents[i][0] == y[p].brush.y().domain()[0]) { + if (dimensionTypes[d] === "number" && !onlyNanValues[d]) { + if ((isNaN(d.values[p]) || isNaN(parseFloat(d.values[p]))) && extents[i][0] == y[p].brush.y().domain()[0]) { + return true; + } + return (extents[i][0] <= d.values[p] && d.values[p] <= extents[i][1]) && !isNaN(parseFloat(d.values[p])); + } else if (dimensionTypes[d] === "enum") { + //d3.select(this).call(axis.scale(y[d])); return true; } - return (extents[i][0] <= d.values[p] && d.values[p] <= extents[i][1]) && !isNaN(parseFloat(d.values[p])); }); if (isActive) active.push(d); @@ -267,7 +297,7 @@ nv.models.parallelCoordinates = function() { function path(d) { return line(enabledDimensions.map(function (p) { //If value if missing, put the value on the missing value line - if (isNaN(d.values[p.key]) || isNaN(parseFloat(d.values[p.key])) || displayMissingValuesline) { + if (dimensionTypes[p] === "number" && (isNaN(d.values[p.key]) || isNaN(parseFloat(d.values[p.key]))) || displayMissingValuesline) { var domain = y[p.key].domain(); var range = y[p.key].range(); var min = domain[0] - (domain[1] - domain[0]) / 9; @@ -351,8 +381,17 @@ nv.models.parallelCoordinates = function() { active = []; //erase current active list foreground.style('display', function(d) { var isActive = actives.every(function(p, i) { - if ((isNaN(d.values[p]) || isNaN(parseFloat(d.values[p]))) && extents[i][0] == y[p].brush.y().domain()[0]) return true; - return (extents[i][0] <= d.values[p] && d.values[p] <= extents[i][1]) && !isNaN(parseFloat(d.values[p])); + if (dimensionTypes[p] === "number") { + if ((isNaN(d.values[p]) || isNaN(parseFloat(d.values[p]))) && extents[i][0] == y[p].brush.y().domain()[0]) return true; + return (extents[i][0] <= d.values[p] && d.values[p] <= extents[i][1]) && !isNaN(parseFloat(d.values[p])); + } else if (dimensionTypes[p] === "enum") { + // If the dimension type is an enum, then we check whether or not the + // output value is in the range by using the ordinal scale. + var rangeValue = y[p](d[p]); + if (rangeValue === undefined && extents[i][1] === y[p].brush.y().range()[0]) return true; + return extents[i][0] <= rangeValue && rangeValue <= extents[i][1]; + } + }); if (isActive) active.push(d); return isActive ? null : 'none'; @@ -422,7 +461,10 @@ nv.models.parallelCoordinates = function() { var v = dragging[d]; return v == null ? x(d) : v; } + }); + + renderWatch.renderEnd("nv-parallelCoordinates immediate"); return chart; } @@ -484,7 +526,8 @@ nv.models.parallelCoordinates = function() { }}, color: {get: function(){return color;}, set: function(_){ color = nv.utils.getColor(_); - }} + }}, + enumerateNonNumericDimensions: {get: function(){return enumerateNonNumericDimensions;}, set: function(_){enumerateNonNumericDimensions=_;}} }); nv.utils.initOptions(chart); return chart; diff --git a/test/mocha/parallelcoordinates.coffee b/test/mocha/parallelcoordinates.coffee new file mode 100644 index 0000000000..98f53759fa --- /dev/null +++ b/test/mocha/parallelcoordinates.coffee @@ -0,0 +1,189 @@ +describe 'NVD3', -> + describe 'Parallel Coordinates', -> + sampleData1 = [ + { + id: 15, + year: 73, + weight: 25.5 + }, + { + id: 25, + year: 62, + weight: 23.2 + }, + { + id: 17, + year: 72, + weight: 25.5 + }, + { + id: 12, + year: 72, + weight: 20.3 + }, + { + id: 12, + year: 71, + weight: 19.5 + } + ]; + + sampleData2 = [ + { + id: 24, + year: 53, + weight: 0.5 + } + ] + + sampleData3 = [ + { + id: "Tudor", + year: 73, + weight: 25.5 + }, + { + id: "Tudor", + year: 62, + weight: 23.2 + }, + { + id: "Windsor", + year: 72, + weight: 25.5 + }, + { + id: "Plantagenet", + year: 72, + weight: 20.3 + }, + { + id: "Plantagenet", + year: 71, + weight: 19.5 + }, + { + id: "Plantagenet", + year: 76, + weight: 29.8 + } + ]; + + options = + margin: + top: 30 + right: 0 + bottom: 10 + left: 0 + width: 200 + height: 200 + dimensionNames: ['id', 'year', 'weight'] + dimensionFormats: ['', '', ''] + lineTension: 0.85 + color: nv.utils.defaultColor() + enumerateNonNumericDimensions: false + + builder = null + beforeEach -> + builder = new ChartBuilder nv.models.parallelCoordinates() + builder.build options, sampleData1 + + afterEach -> + builder.teardown() + + it 'api check', -> + should.exist builder.model.options, 'options exposed' + for opt of options + should.exist builder.model[opt], "#{opt} exists" + should.exist builder.model[opt](), "#{opt} can be called" + + it 'renders', -> + wrap = builder.$ 'g.nvd3.nv-parallelCoordinates' + should.exist wrap[0] + + it 'clears chart objects for no data', -> + builder = new ChartBuilder nv.models.parallelCoordinates() + builder.buildover options, sampleData1, [] + + groups = builder.$ 'path.domain' + groups.length.should.equal 3, 'only vertical axes paths remain' + + it 'has correct structure', -> + cssClasses = [ + '.background' + '.foreground' + '.missingValuesline' + '.dimension' + '.nv-axis' + '.nv-label' + ] + + for cssClass in cssClasses + do (cssClass) -> + should.exist builder.$("g.nv-parallelCoordinates #{cssClass}")[0], "class: " + cssClass + + it 'has path (foreground and background) for each data entry', -> + points = builder.$ 'path' + points.should.have.length sampleData1.length * 2 + 3 + + it 'has the correct number of axes', -> + axes = builder.$ 'path.domain' + axes.should.have.length 3 + + it 'has a label for each axis', -> + labels = builder.$ 'text.nv-parallelCoordinates.nv-label' + labels.should.have.length 3 + + it 'can update with new data', -> + builder.updateData(sampleData2) + + expAxes = 3 + axesLabels = builder.$ '.nv-parallelCoordinates.nv-label' + axesLabels.should.have.length expAxes, 'expected num vertical axes labels' + + axes = builder.$ 'path.domain' + axes.should.have.length expAxes, 'expected num vertical axes' + + paths = builder.$ 'path' + paths.should.have.length sampleData2.length * 2 + expAxes, 'expected num paths' + + it 'treats non-numeric dimensions as undefined by default', -> + builder.teardown() + builder.build options, sampleData3 + + expAxes = 2 + axesLabels = builder.$ '.nv-parallelCoordinates.nv-label' + axesLabels.should.have.length expAxes + 1, 'expected num vertical axes labels' + + axes = builder.$ 'path.domain' + axes.should.have.length expAxes, 'expected num vertical axes' + + paths = builder.$ 'path' + paths.should.have.length sampleData3.length * 2 + expAxes, 'expected num paths' + + it 'can enumerate non-numeric dimensions', -> + newOptions = + width: 200 + height: 200 + dimensionNames: ['id', 'year', 'weight'] + dimensionFormats: ['', '', ''] + enumerateNonNumericDimensions: true + builder.teardown() + builder.build newOptions, sampleData3 + + expAxes = 3 + axesLabels = builder.$ '.nv-parallelCoordinates.nv-label' + axesLabels.should.have.length expAxes, 'expected num vertical axes labels' + + axes = builder.$ 'path.domain' + axes.should.have.length expAxes, 'expected num vertical axes' + + paths = builder.$ 'path' + paths.should.have.length sampleData3.length * 2 + expAxes, 'expected num paths' + + # Grab the first axis and check that it has the correct number of ticks: + # one for each unique value (for that dimension) in the dataset + axis = builder.svg.querySelector('g.nv-parallelCoordinates.nv-axis') + ticks = axis.querySelectorAll('g.tick') + ticks.should.have.length 3, 'expected ticks on first vertical axis' +