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'
+