diff --git a/Changelog.md b/Changelog.md index 70baafd9fb..31d1dbeb65 100644 --- a/Changelog.md +++ b/Changelog.md @@ -2,6 +2,8 @@ * Heatmap allows customizing the ordering separately from the values, by Matt Traynham ([#869](https://github.com/dc-js/dc.js/pull/869) - thanks also to Quinn Lee for [#837](https://github.com/dc-js/dc.js/pull/837)) * Front page stable version automatically read from GitHub, by Enrico Spinielli ([#865](https://github.com/dc-js/dc.js/pull/865)) * Functional-style filter handlers: instead of modifying the array of filters in-place, filter handlers must return the new filter array. This is consistent with the old documention, but a different implementation: any changes to the `filters` argument will be ignored unless they are returned. This should make filter handlers easier to reason about. + * Row chart improvements `useRightYAxis`, `xAxisLabel`, by Timothy Ruhle + * Paired row chart, by Timothy Ruhle ([#510](https://github.com/dc-js/dc.js/issues/510) / [#943](https://github.com/dc-js/dc.js/pull/943)) # 2.0 Series ## 2.0.0 beta 23 diff --git a/Gruntfile.js b/Gruntfile.js index 81d36ef983..b8e5969dc1 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -403,6 +403,7 @@ module.exports.jsFiles = [ 'src/geo-choropleth-chart.js', 'src/bubble-overlay.js', 'src/row-chart.js', + 'src/paired-row-chart.js', 'src/legend.js', 'src/scatter-plot.js', 'src/number-display.js', diff --git a/dc.css b/dc.css index c046cd72b8..75b7a89167 100644 --- a/dc.css +++ b/dc.css @@ -324,3 +324,7 @@ g.dc-legend-item.fadeout { pointer-events: all; cursor: pointer; } + +.dc-chart g.row text.titlerow { + fill: #000000; +} diff --git a/spec/helpers/fixtures.js b/spec/helpers/fixtures.js index 7f37a02508..7c2c173cc1 100644 --- a/spec/helpers/fixtures.js +++ b/spec/helpers/fixtures.js @@ -77,5 +77,39 @@ function loadColorFixture2 () { "{\"colData\":\"6\", \"rowData\": \"6\", \"colorData\": \"8\"}" + "]"); } + +function loadGenderFixture () { + return JSON.parse("[" + + "{\"gender\":\"Male\", \"age\": \"10\"}," + + "{\"gender\":\"Male\", \"age\": \"18\"}," + + "{\"gender\":\"Male\", \"age\": \"20\"}," + + "{\"gender\":\"Male\", \"age\": \"22\"}," + + "{\"gender\":\"Male\", \"age\": \"30\"}," + + "{\"gender\":\"Male\", \"age\": \"40\"}," + + "{\"gender\":\"Male\", \"age\": \"45\"}," + + "{\"gender\":\"Male\", \"age\": \"50\"}," + + "{\"gender\":\"Male\", \"age\": \"55\"}," + + "{\"gender\":\"Male\", \"age\": \"70\"}," + + "{\"gender\":\"Male\", \"age\": \"80\"}," + + "{\"gender\":\"Male\", \"age\": \"90\"}," + + "{\"gender\":\"Male\", \"age\": \"100\"}," + + "{\"gender\":\"Male\", \"age\": \"101\"}," + + "{\"gender\":\"Female\", \"age\": \"11\"}," + + "{\"gender\":\"Female\", \"age\": \"15\"}," + + "{\"gender\":\"Female\", \"age\": \"20\"}," + + "{\"gender\":\"Female\", \"age\": \"21\"}," + + "{\"gender\":\"Female\", \"age\": \"22\"}," + + "{\"gender\":\"Female\", \"age\": \"30\"}," + + "{\"gender\":\"Female\", \"age\": \"40\"}," + + "{\"gender\":\"Female\", \"age\": \"50\"}," + + "{\"gender\":\"Female\", \"age\": \"60\"}," + + "{\"gender\":\"Female\", \"age\": \"65\"}," + + "{\"gender\":\"Female\", \"age\": \"70\"}," + + "{\"gender\":\"Female\", \"age\": \"80\"}," + + "{\"gender\":\"Female\", \"age\": \"90\"}," + + "{\"gender\":\"Female\", \"age\": \"100\"}" + + "]"); +} + /* jscs:enable validateQuoteMarks, maximumLineLength */ /* jshint +W109, +W101, +W098 */ diff --git a/spec/paired-row-chart-spec.js b/spec/paired-row-chart-spec.js new file mode 100644 index 0000000000..a02b59a2c3 --- /dev/null +++ b/spec/paired-row-chart-spec.js @@ -0,0 +1,141 @@ +/* global appendChartID, loadGenderFixture */ +describe('dc.pairedRowChart', function () { + var id, chart; + var data, dimension, group, dummyGroup; + + beforeEach(function () { + var genderFixture = loadGenderFixture(); + data = crossfilter(genderFixture); + var ageRanges = ['0 - 9', '10 - 19', '20 - 29', '30 - 39', '40 - 49', '50 - 59', + '60 - 69', '70 - 79', '80 - 89', '90 - 99', '100+']; + + dimension = data.dimension(function (d) { + var ageRange = (d.age <= 99) ? + ageRanges[Math.floor(d.age / 10)] : + ageRanges[10]; + + return [d.gender, ageRange]; + }); + group = dimension.group().reduceCount(); + + dummyGroup = { + all: function () { + // convert to object so we can easily tell if a key exists + var values = {}; + group.all().forEach(function (d) { + values[d.key[0] + '.' + d.key[1]] = d.value; + }); + + // convert back into an array for the chart, making sure that all ageRanges exist + var g = []; + ageRanges.forEach(function (ageRange) { + g.push({ + key: ['Male', ageRange], + value: values['Male.' + ageRange] || 0 + }); + g.push({ + key: ['Female', ageRange], + value: values['Female.' + ageRange] || 0 + }); + }); + + return g; + } + }; + + id = 'paired-row-chart'; + appendChartID(id); + + chart = dc.pairedRowChart('#' + id); + chart.dimension(dimension) + .group(dummyGroup) + .width(600).height(200).gap(10) + .leftKeyFilter(function (d) { + return d.key[0] === 'Male'; + }) + .rightKeyFilter(function (d) { + return d.key[0] === 'Female'; + }) + .transitionDuration(0); + }); + + describe('leftChart', function () { + beforeEach(function () { + chart.render(); + }); + + describe('useRightYAxis', function () { + it('should use right y axis', function () { + expect(chart.leftChart().useRightYAxis()).toBe(true); + }); + }); + + describe('key filter', function () { + it('can get key filter', function () { + expect(typeof chart.leftKeyFilter()).toBe('function'); + }); + + it('should filter data', function () { + expect(chart.leftChart().data().length < dummyGroup.all().length).toBe(true); + }); + }); + + describe('margins', function () { + it('should manually set margins', function () { + var margins = chart.margins(), + leftMargins = chart.leftChart().margins(); + + expect(leftMargins.top).toBe(margins.top); + expect(leftMargins.right).toBe(0); + expect(leftMargins.bottom).toBe(margins.bottom); + expect(leftMargins.left).toBe(margins.left); + }); + }); + + describe('calculateAxisScaleData', function () { + it('should equal the group data', function () { + expect(chart.leftChart().calculateAxisScaleData().length).toBe(dummyGroup.all().length); + }); + }); + }); + + describe('rightChart', function () { + beforeEach(function () { + chart.render(); + }); + + describe('useRightYAxis', function () { + it('should not use right y axis', function () { + expect(chart.rightChart().useRightYAxis()).toBe(false); + }); + }); + + describe('key filter', function () { + it('can get key filter', function () { + expect(typeof chart.rightKeyFilter()).toBe('function'); + }); + + it('should filter data', function () { + expect(chart.rightChart().data().length < dummyGroup.all().length).toBe(true); + }); + }); + + describe('margins', function () { + it('should manually set margins', function () { + var margins = chart.margins(), + rightMargins = chart.rightChart().margins(); + + expect(rightMargins.top).toBe(margins.top); + expect(rightMargins.right).toBe(margins.right); + expect(rightMargins.bottom).toBe(margins.bottom); + expect(rightMargins.left).toBe(0); + }); + }); + + describe('calculateAxisScaleData', function () { + it('should equal the group data', function () { + expect(chart.rightChart().calculateAxisScaleData().length).toBe(dummyGroup.all().length); + }); + }); + }); +}); diff --git a/spec/row-chart-spec.js b/spec/row-chart-spec.js index 687e99082d..39b1daa128 100644 --- a/spec/row-chart-spec.js +++ b/spec/row-chart-spec.js @@ -75,6 +75,65 @@ describe('dc.rowChart', function () { }); }); + describe('_useRightYAxis', function () { + beforeEach(function () { + chart.group(positiveGroupHolder.group); + }); + + describe('is false', function () { + beforeEach(function () { + chart.useRightYAxis(false); + chart.render(); + }); + + describe('rows', function () { + it('should not translate', function () { + expect(chart.selectAll('.row').attr('transform')).toBe('translate(0,10)'); + }); + }); + + describe('bars', function () { + it('should not translate', function () { + expect(chart.selectAll('.row rect').attr('transform')).toBe('translate(0,0)'); + }); + }); + + describe('labels', function () { + it('should position the label by the end', function () { + expect(chart.selectAll('.row text').attr('text-anchor')).toBe('start'); + }); + }); + }); + + describe('is true', function () { + beforeEach(function () { + chart.useRightYAxis(true); + chart.render(); + }); + + describe('rows', function () { + it('should translate to the width of the chart', function () { + expect(chart.selectAll('.row').attr('transform')).toBe('translate(' + chart.effectiveWidth() + ',10)'); + }); + }); + + describe('bars', function () { + it('should translate to its own width', function () { + var rect = chart.selectAll('.row rect'); + + expect(rect.attr('transform')).toBe('translate(-' + rect[0][0].getBBox().width + ',0)'); + }); + }); + + describe('labels', function () { + it('should position the label by the end', function () { + expect(chart.selectAll('.row text').attr('text-anchor')).toBe('end'); + }); + }); + }); + + }); + function itShouldBehaveLikeARowChartWithGroup (groupHolder, N) { describe('for ' + groupHolder.groupType + ' data', function () { beforeEach(function () { diff --git a/src/paired-row-chart.js b/src/paired-row-chart.js new file mode 100644 index 0000000000..2e2b6c7f4c --- /dev/null +++ b/src/paired-row-chart.js @@ -0,0 +1,227 @@ +/** +## Paired Row Chart +Includes: [Cap Mixin](#cap-mixin), [Margin Mixin](#margin-mixin), [Color Mixin](#color-mixin), [Base Mixin](#base-mixin) + +Concrete paired row chart implementation. +#### dc.pairedRowChart(parent[, chartGroup]) +Create a paired row chart instance and attach it to the given parent element. + +Parameters: + +* parent : string | node | selection - any valid + [d3 single selector](https://github.com/mbostock/d3/wiki/Selections#selecting-elements) specifying + a dom block element such as a div; or a dom element or d3 selection. + +* chartGroup : string (optional) - name of the chart group this chart instance should be placed in. + Interaction with a chart will only trigger events and redraws within the chart's group. + +Returns: +A newly created paired row chart instance + +```js +// create a paired row chart under #chart-container1 element using the default global chart group +var chart1 = dc.pairedRowChart('#chart-container1'); +// create a paired row chart under #chart-container2 element using chart group A +var chart2 = dc.pairedRowChart('#chart-container2', 'chartGroupA'); +``` +**/ +dc.pairedRowChart = function (parent, chartGroup) { + var _chart = dc.capMixin(dc.marginMixin(dc.colorMixin(dc.baseMixin({})))); + + var _leftChartWrapper = d3.select(parent).append('div').style('width', '50%').style('display', 'inline-block'); + var _rightChartWrapper = d3.select(parent).append('div').style('width', '50%').style('display', 'inline-block'); + + var _leftChart = dc.rowChart(_leftChartWrapper[0][0], chartGroup); + var _rightChart = dc.rowChart(_rightChartWrapper[0][0], chartGroup); + + _leftChart.useRightYAxis(true); + + // data filtering + + // we need a way to know which data belongs on the left chart and which data belongs on the right + var _leftKeyFilter = function (d) { + return d.key[0]; + }; + + var _rightKeyFilter = function (d) { + return d.key[0]; + }; + + /** + #### .leftKeyFilter([value]) - **mandatory** + Set or get the left key filter attribute of a chart. + + For example + function(d) { + return d.key[0] === 'Male'; + } + + If a value is given, then it will be used as the new left key filter. If no value is specified then + the current left key filter will be returned. + + **/ + _chart.leftKeyFilter = function (_) { + if (!arguments.length) { + return _leftKeyFilter; + } + + _leftKeyFilter = _; + return _chart; + }; + + /** + #### .rightKeyFilter([value]) - **mandatory** + Set or get the right key filter attribute of a chart. + + For example + function(d) { + return d.key[0] === 'Female'; + } + + If a value is given, then it will be used as the new right key filter. If no value is specified then + the current right key filter will be returned. + + **/ + _chart.rightKeyFilter = function (_) { + if (!arguments.length) { + return _rightKeyFilter; + } + + _rightKeyFilter = _; + return _chart; + }; + + // when trying to get the data for the left chart then filter all data using the leftKeyFilter function + _leftChart.data(function (data) { + var cap = _leftChart.cap(), + d = data.all().filter(function (d) { + return _chart.leftKeyFilter()(d); + }); + + if (cap === Infinity) { + return d; + } + + return d.slice(0, cap); + }); + + // when trying to get the data for the right chart then filter all data using the rightKeyFilter function + _rightChart.data(function (data) { + var cap = _rightChart.cap(), + d = data.all().filter(function (d) { + return _chart.rightKeyFilter()(d); + }); + + if (cap === Infinity) { + return d; + } + + return d.slice(0, cap); + }); + + // chart filtering + // on clicking either chart then filter both + + _leftChart.onClick = _rightChart.onClick = function (d) { + var filter = _leftChart.keyAccessor()(d); + dc.events.trigger(function () { + _leftChart.filter(filter); + _rightChart.filter(filter); + _leftChart.redrawGroup(); + }); + }; + + // margins + var leftMargins = _leftChart.margins; + var rightMargins = _rightChart.margins; + + _leftChart.margins = function () { + var margins = leftMargins(); + margins.right = 0; + return margins; + }; + + _rightChart.margins = function () { + var margins = rightMargins(); + margins.left = 0; + return margins; + }; + + // svg + // return an array of both the sub chart svgs + + _chart.svg = function () { + return d3.selectAll([_leftChart.svg()[0][0], _rightChart.svg()[0][0]]); + }; + + // we need to make sure that the extent is the same for both charts + _leftChart.calculateAxisScaleData = _rightChart.calculateAxisScaleData = function () { + return _leftChart.data().concat(_rightChart.data()); + }; + + // get the charts - mainly used for testing + _chart.leftChart = function () { + return _leftChart; + }; + + _chart.rightChart = function () { + return _rightChart; + }; + + _chart.leftXAxisLabel = function (labelText, padding) { + return _leftChart.xAxisLabel(labelText, padding); + }; + + _chart.rightXAxisLabel = function (labelText, padding) { + return _rightChart.xAxisLabel(labelText, padding); + }; + + // functions that we just want to pass on to both sub charts + + var _getterSetterPassOn = [ + // display + 'height', 'minHeight', 'renderTitleLabel', 'fixedBarHeight', 'gap', 'othersLabel', + 'transitionDuration', 'label', 'renderLabel', 'title', 'renderTitle', 'chartGroup', + //colors + 'colors', 'ordinalColors', 'linearColors', 'colorAccessor', 'colorDomain', 'getColor', 'colorCalculator', + // x axis + 'x', 'elasticX', 'valueAccessor', 'labelOffsetX', 'titleLabelOffsetx', 'xAxis', + // y axis + 'keyAccessor', 'labelOffsetY', 'yAxis', + // data + 'cap', 'ordering' , 'dimension', 'group', 'othersGrouper', 'data' + ]; + + function addGetterSetterFunction (functionName) { + _chart[functionName] = function (_) { + if (!arguments.length) { + return [_leftChart[functionName](), _rightChart[functionName]()]; + } + _leftChart[functionName](_); + _rightChart[functionName](_); + return _chart; + }; + } + + for (var i = 0; i < _getterSetterPassOn.length; i++) { + addGetterSetterFunction(_getterSetterPassOn[i]); + } + + var _passOnFunctions = [ + '_doRedraw', 'redraw', '_doRender', 'render', 'calculateColorDomain', 'filterAll', 'resetSvg', 'expireCache' + ]; + + function addPassOnFunctions (functionName) { + _chart[functionName] = function () { + _leftChart[functionName](); + _rightChart[functionName](); + return _chart; + }; + } + + for (i = 0; i < _passOnFunctions.length; i++) { + addPassOnFunctions(_passOnFunctions[i]); + } + + return _chart.anchor(parent, chartGroup); +}; diff --git a/src/row-chart.js b/src/row-chart.js index e575cc5f1f..2930e7fdda 100644 --- a/src/row-chart.js +++ b/src/row-chart.js @@ -23,6 +23,9 @@ */ dc.rowChart = function (parent, chartGroup) { + var X_AXIS_LABEL_CLASS = 'x-axis-label'; + var DEFAULT_AXIS_LABEL_PADDING = 12; + var _g; var _labelOffsetX = 10; @@ -31,6 +34,9 @@ dc.rowChart = function (parent, chartGroup) { var _dyOffset = '0.35em'; // this helps center labels https://github.com/mbostock/d3/wiki/SVG-Shapes#svg_text var _titleLabelOffsetX = 2; + var _xAxisLabel; + var _xAxisLabelPadding = 0; + var _gap = 5; var _fixedBarHeight = false; @@ -48,16 +54,27 @@ dc.rowChart = function (parent, chartGroup) { var _rowData; + var _useRightYAxis = false; + _chart.rowsCap = _chart.cap; + _chart.calculateAxisScaleData = function () { + return _rowData; + }; + function calculateAxisScale () { if (!_x || _elasticX) { - var extent = d3.extent(_rowData, _chart.cappedValueAccessor); + var extent = d3.extent(_chart.calculateAxisScaleData(), _chart.cappedValueAccessor); if (extent[0] > 0) { extent[0] = 0; } - _x = d3.scale.linear().domain(extent) - .range([0, _chart.effectiveWidth()]); + var domain = d3.scale.linear().domain(extent); + + if (_useRightYAxis) { + _x = domain.range([_chart.effectiveWidth(), 0]); + } else { + _x = domain.range([0, _chart.effectiveWidth()]); + } } _xAxis.scale(_x); } @@ -74,6 +91,30 @@ dc.rowChart = function (parent, chartGroup) { dc.transition(axisG, _chart.transitionDuration()) .call(_xAxis); + + renderXAxisLabel(); + } + + function renderXAxisLabel () { + var axisXLab = _g.selectAll('text.' + X_AXIS_LABEL_CLASS); + + if (axisXLab.empty() && _chart.xAxisLabel()) { + axisXLab = _g.append('text') + .attr('class', X_AXIS_LABEL_CLASS) + .attr('transform', + 'translate(' + (_chart.xAxisLength() / 2) + + ',' + (_chart.height() - _xAxisLabelPadding) + ')' + ) + .attr('text-anchor', 'middle'); + } + + if (_chart.xAxisLabel() && axisXLab.text() !== _chart.xAxisLabel()) { + axisXLab.text(_chart.xAxisLabel()); + } + + dc.transition(axisXLab, _chart.transitionDuration()) + .attr('transform', 'translate(' + (_chart.xAxisLength() / 2) + ',' + + (_chart.height() - _xAxisLabelPadding) + ')'); } _chart._doRender = function () { @@ -181,7 +222,9 @@ dc.rowChart = function (parent, chartGroup) { } var rect = rows.attr('transform', function (d, i) { - return 'translate(0,' + ((i + 1) * _gap + i * height) + ')'; + var h = ((i + 1) * _gap + i * height), + w = _useRightYAxis ? _chart.effectiveWidth() : 0; + return 'translate(' + w + ',' + h + ')'; }).select('rect') .attr('height', height) .attr('fill', _chart.getColor) @@ -225,9 +268,10 @@ dc.rowChart = function (parent, chartGroup) { function updateLabels (rows) { if (_chart.renderLabel()) { var lab = rows.select('text') - .attr('x', _labelOffsetX) + .attr('x', _useRightYAxis ? -_labelOffsetX : _labelOffsetX) .attr('y', _labelOffsetY) .attr('dy', _dyOffset) + .attr('text-anchor', _useRightYAxis ? 'end' : 'start') .on('click', onClick) .attr('class', function (d, i) { return _rowCssClass + ' _' + i; @@ -236,13 +280,21 @@ dc.rowChart = function (parent, chartGroup) { return _chart.label()(d); }); dc.transition(lab, _chart.transitionDuration()) - .attr('transform', translateX); + .attr('transform', function (d) { + if (_useRightYAxis) { + return 'translate(0,0)'; + } + return translateX(d); + }); } if (_chart.renderTitleLabel()) { var titlelab = rows.select('.' + _titleRowCssClass) - .attr('x', _chart.effectiveWidth() - _titleLabelOffsetX) + .attr('x', _useRightYAxis ? + _titleLabelOffsetX - _chart.effectiveWidth() : + _chart.effectiveWidth() - _titleLabelOffsetX + ) .attr('y', _labelOffsetY) - .attr('text-anchor', 'end') + .attr('text-anchor', _useRightYAxis ? 'start' : 'end') .on('click', onClick) .attr('class', function (d, i) { return _titleRowCssClass + ' _' + i ; @@ -251,7 +303,12 @@ dc.rowChart = function (parent, chartGroup) { return _chart.title()(d); }); dc.transition(titlelab, _chart.transitionDuration()) - .attr('transform', translateX); + .attr('transform', function (d) { + if (_useRightYAxis) { + return 'translate(0,0)'; + } + return translateX(d); + }); } } @@ -280,6 +337,11 @@ dc.rowChart = function (parent, chartGroup) { var x = _x(_chart.cappedValueAccessor(d)), x0 = rootValue(), s = x > x0 ? x0 : x; + + if (_useRightYAxis) { + s -= _chart.effectiveWidth(); + } + return 'translate(' + s + ',0)'; } @@ -364,6 +426,31 @@ dc.rowChart = function (parent, chartGroup) { return _chart; }; + _chart.xAxisLength = function () { + return _chart.effectiveWidth(); + }; + + /** + * Set or get the x axis label. If setting the label, you may optionally include additional padding to + * the margin to make room for the label. By default the padded is set to 12 to accomodate the text height. + * @name xAxisLabel + * @memberof dc.coordinateGridMixin + * @instance + * @param {String} [labelText] + * @param {Number} [padding=12] + * @return {String} + */ + _chart.xAxisLabel = function (labelText, padding) { + if (!arguments.length) { + return _xAxisLabel; + } + _xAxisLabel = labelText; + _chart.margins().bottom -= _xAxisLabelPadding; + _xAxisLabelPadding = (padding === undefined) ? DEFAULT_AXIS_LABEL_PADDING : padding; + _chart.margins().bottom += _xAxisLabelPadding; + return _chart; + }; + /** * Get or set the x offset (horizontal space to the top left corner of a row) for labels on a particular row chart. * @name labelOffsetX @@ -416,6 +503,24 @@ dc.rowChart = function (parent, chartGroup) { return _chart; }; + /** + * Gets or sets whether the chart should be drawn with a right axis instead of a left axis. + * @name useRightYAxis + * @memberof dc.rowChart + * @instance + * @param {Number} [useRightYAxis=false] + * @return {Number} + * @return {dc.rowChart} + **/ + + _chart.useRightYAxis = function (useRightYAxis) { + if (!arguments.length) { + return _useRightYAxis; + } + _useRightYAxis = useRightYAxis; + return _chart; + }; + function isSelectedRow (d) { return _chart.hasFilter(_chart.cappedKeyAccessor(d)); } diff --git a/web/examples/paired-row.html b/web/examples/paired-row.html new file mode 100644 index 0000000000..7a38bb910e --- /dev/null +++ b/web/examples/paired-row.html @@ -0,0 +1,130 @@ + + +
+