diff --git a/README.md b/README.md index b2c393e..e894493 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ new Chartist.Bar('.ct-chart', data, { | --- | --- | --- | --- | | `className` | Adds a class to the `ul` element. | `string` | `''` | | `clickable` | Sets the legends clickable state; setting this value to `false` disables toggling graphs on legend click. | `bool` | `true` | -| `legendNames` | Sets custom legend names. By default the `name` property of the series will be used if none are given. | `mixed` | `false` | +| `legendNames` | Sets custom legend names. By default the `name` property of the series will be used if none are given. Multiple series can be associated with a legend item using this property as well. See examples for more details. | `mixed` | `false` | | `onClick` | Accepts a function that gets invoked if `clickable` is true. The function has the `chart`, and the click event (`e`), as arguments. | `mixed` | `false` | | `classNames` | Accepts a array of strings as long as the chart's series, those will be added as classes to the `li` elements. | `mixed` | `false` | | `removeAll` | Allow all series to be removed at once. | `bool` | `false` | diff --git a/chartist-plugin-legend.js b/chartist-plugin-legend.js index 30286b7..930248a 100644 --- a/chartist-plugin-legend.js +++ b/chartist-plugin-legend.js @@ -33,10 +33,6 @@ Chartist.plugins.legend = function (options) { - function compareNumbers(a, b) { - return a - b; - } - // Catch invalid options if (options && options.position) { if (!(options.position === 'top' || options.position === 'bottom' || options.position instanceof HTMLElement)) { @@ -57,6 +53,25 @@ } return function legend(chart) { + function updateChart() { + var newSeries = []; + var newLabels = []; + + for (var i = 0; i < seriesMetadata.length; i++) { + if(seriesMetadata[i].legend != -1 && legends[seriesMetadata[i].legend].active) { + newSeries.push(seriesMetadata[i].data); + newLabels.push(seriesMetadata[i].label); + } + } + + chart.data.series = newSeries; + if (useLabels) { + chart.data.labels = newLabels; + } + + chart.update(); + } + var existingLegendElement = chart.container.querySelector('.ct-legend'); if (existingLegendElement) { // Clear legend if already existing. @@ -93,9 +108,6 @@ legendElement.style.cssText = 'width: ' + chart.options.width + 'px;margin: 0 auto;'; } - var removedSeries = [], - originalSeries = chart.data.series.slice(0); - // Get the right array to use for generating the legend. var legendNames = chart.data.series, useLabels = isPieChart && chart.data.labels; @@ -104,12 +116,29 @@ legendNames = chart.data.labels; } legendNames = options.legendNames || legendNames; + + var legends = []; + var seriesMetadata = new Array(chart.data.series.length); + var activeLegendCount = 0; + + // Initialize the array that associates series with legends. + // -1 indicates that there is no legend associated with it. + for (var i = 0; i < chart.data.series.length; i++) { + seriesMetadata[i] = { + data: chart.data.series[i], + label: useLabels ? chart.data.labels[i] : null, + legend: -1 + }; + } // Check if given class names are viable to append to legends var classNamesViable = (Array.isArray(options.classNames) && (options.classNames.length === legendNames.length)); // Loop through all legends to set each name in a list item. legendNames.forEach(function (legend, i) { + var legendText = legend.name || legend; + var legendSeries = legend.series || [i]; + var li = document.createElement('li'); li.className = 'ct-series-' + i; // Append specific class to a legend element, if viable classes are given @@ -117,8 +146,20 @@ li.className += ' ' + options.classNames[i]; } li.setAttribute('data-legend', i); - li.textContent = legend.name || legend; + li.textContent = legendText; legendElement.appendChild(li); + + legendSeries.forEach(function(seriesIndex) { + seriesMetadata[seriesIndex].legend = i; + }); + + legends.push({ + text: legendText, + series: legendSeries, + active: true + }); + + activeLegendCount++; }); chart.on('created', function (data) { @@ -148,66 +189,36 @@ return; e.preventDefault(); - var seriesIndex = parseInt(li.getAttribute('data-legend')), - removedSeriesIndex = removedSeries.indexOf(seriesIndex); + var legendIndex = parseInt(li.getAttribute('data-legend')); + var legend = legends[legendIndex]; - if (removedSeriesIndex > -1) { - // Add to series again. - removedSeries.splice(removedSeriesIndex, 1); + if (!legends[legendIndex].active) { + legend.active = true; + activeLegendCount++; li.classList.remove('inactive'); } else { - if (!options.removeAll) { - // Remove from series, only if a minimum of one series is still visible. - if ( chart.data.series.length > 1) { - removedSeries.push(seriesIndex); - li.classList.add('inactive'); - } - // Set all series as active. - else { - removedSeries = []; - var seriesItems = Array.prototype.slice.call(legendElement.childNodes); - seriesItems.forEach(function (item) { - item.classList.remove('inactive'); - }); - } - } - else { - // Remove series unaffected if it is the last or not - removedSeries.push(seriesIndex); - li.classList.add('inactive'); + legend.active = false; + activeLegendCount--; + li.classList.add('inactive'); + + if (!options.removeAll && activeLegendCount == 0) { + //If we can't disable all series at the same time, let's + //reenable all of them: + for (var i = 0; i < legends.length; i++) { + legends[i].active = true; + activeLegendCount++; + legendElement.childNodes[i].classList.remove('inactive'); + } } } - - // Reset the series to original and remove each series that - // is still removed again, to remain index order. - var seriesCopy = originalSeries.slice(0); - if (useLabels) { - var labelsCopy = originalLabels.slice(0); - } - - // Reverse sort the removedSeries to prevent removing the wrong index. - removedSeries.sort(compareNumbers).reverse(); - - removedSeries.forEach(function (series) { - seriesCopy.splice(series, 1); - if (useLabels) { - labelsCopy.splice(series, 1); - } - }); - + + updateChart(); + if (options.onClick) { options.onClick(chart, e); } - - chart.data.series = seriesCopy; - if (useLabels) { - chart.data.labels = labelsCopy; - } - - chart.update(); }); } - }; }; diff --git a/index.html b/index.html index 25278b8..6255785 100644 --- a/index.html +++ b/index.html @@ -70,6 +70,20 @@ background-color: #453d3f; border-color: #453d3f; } + + .ct-chart-line-multipleseries .ct-legend .ct-series-0:before { + background-color: #d70206; + border-color: #d70206; + } + + .ct-chart-line-multipleseries .ct-legend .ct-series-1:before { + background-color: #f4c63d; + border-color: #f4c63d; + } + + .ct-chart-line-multipleseries .ct-legend li.inactive:before { + background: transparent; + } .crazyPink li.ct-series-0:before { background-color: #C2185B; @@ -341,6 +355,28 @@

Chart with legendNames:

legendNames: ['Custom title', 'Another one', 'And the last one'], }) ] +}); +

Chart with multiple series per item:

+ The legendNames property can be used to associate multiple series with a legend item. +
+ +
new Chartist.Line('.ct-chart-line-multipleseries', {
+   labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
+   series: [
+       [12, 9, 7, 8, 5],
+       [2, 1, 3.5, 7, 3],
+       [1, 3, 4, 5, 6]
+   ]
+}, {
+   fullWidth: true,
+   chartPadding: {
+      right: 40
+   },
+   plugins: [
+       Chartist.plugins.legend({
+          legendNames: [{name: 'Red-ish', series: [0,1]}, {name: 'Yellow', series: [2]}],
+       })
+   ]
 });

Chart with onClick:

Accepts a function that gets invoked if clickable is true. The function has the chart, and the click event (e), as arguments. @@ -564,6 +600,25 @@

Chart with position:

}) ] }); + + new Chartist.Line('.ct-chart-line-multipleseries', { + labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], + series: [ + [12, 9, 7, 8, 5], + [2, 1, 3.5, 7, 3], + [1, 3, 4, 5, 6] + ] + }, { + fullWidth: true, + chartPadding: { + right: 40 + }, + plugins: [ + Chartist.plugins.legend({ + legendNames: [{name: 'Red-ish', series: [0,1]}, {name: 'Yellow', series: [2]}], + }) + ] + }); new Chartist.Line('.ct-chart-line-onclick', { labels: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], diff --git a/test/test.legend.js b/test/test.legend.js index 689ebf4..3a9dfb7 100644 --- a/test/test.legend.js +++ b/test/test.legend.js @@ -402,9 +402,128 @@ describe('Chartist plugin legend', function() { click(seriesB); expect(chart.legendClicked).to.equal(true); + + //Clicking on an inactive series should also call the function. + chart.legendClicked = false; + click(seriesB); + expect(chart.legendClicked).to.equal(true); }); }); + + describe('clickable with multiple series per legend item', function() { + before(function(done) { + chart = generateChart('Line', chartDataLine, { + clickable: true, + onClick: function(chart,e) { + chart.legendClicked = true; + }, + legendNames: [{name: 'Red-ish', series: [0,1]}, {name: 'Yellow', series: [2]}] + }); + + chart.on('created', function() { + chart.off('created'); + done(); + }); + }); + + after(destroyChart); + + it('should enforce a className for each series', function() { + expect(chart.data.series[0].className).to.equal('ct-series-a'); + expect(chart.data.series[1].className).to.equal('ct-series-b'); + expect(chart.data.series[2].className).to.equal('ct-series-c'); + }); + + it('should hide a series after a click on the legend item', function() { + var seriesA = chart.container.querySelector('ul.ct-legend > .ct-series-0'); + var seriesB = chart.container.querySelector('ul.ct-legend > .ct-series-1'); + + expect(chart.data.series.length).to.equal(3); + // Clicking on the second legend item should hide the last series: + click(seriesB); + expect(chart.data.series.length).to.equal(2); + expect(chart.data.series[0].name).to.equal('Blue pill'); + expect(chart.data.series[1].name).to.equal('Red pill'); + var svgSeries = chart.container.querySelectorAll('g.ct-series'); + expect(svgSeries.length).to.equal(2); + expect(svgSeries[0].className.baseVal).to.contain('ct-series-a'); + expect(svgSeries[1].className.baseVal).to.contain('ct-series-b'); + + // A second click should show the corresponding series again. + click(seriesB); + var svgSeries2 = chart.container.querySelectorAll('g.ct-series'); + expect(svgSeries2.length).to.equal(3); + expect(svgSeries2[0].className.baseVal).to.contain('ct-series-a'); + expect(svgSeries2[1].className.baseVal).to.contain('ct-series-b'); + expect(svgSeries2[2].className.baseVal).to.contain('ct-series-c'); + + // Clicking on the first legend item should hide the two first series: + click(seriesA); + expect(chart.data.series.length).to.equal(1); + expect(chart.data.series[0].name).to.equal('Purple pill'); + var svgSeries = chart.container.querySelectorAll('g.ct-series'); + expect(svgSeries.length).to.equal(1); + expect(svgSeries[0].className.baseVal).to.contain('ct-series-c'); + + // A second click should show the both series again. + click(seriesA); + var svgSeries2 = chart.container.querySelectorAll('g.ct-series'); + expect(svgSeries2.length).to.equal(3); + expect(svgSeries2[0].className.baseVal).to.contain('ct-series-a'); + expect(svgSeries2[1].className.baseVal).to.contain('ct-series-b'); + expect(svgSeries2[2].className.baseVal).to.contain('ct-series-c'); + + // A click in the last active series should set all series active again. + click(seriesA); + expect(chart.data.series.length).to.equal(1); + expect(chart.data.series[0].name).to.equal('Purple pill'); + click(seriesB); + expect(svgSeries2.length).to.equal(3); + expect(svgSeries2[0].className.baseVal).to.contain('ct-series-a'); + expect(svgSeries2[1].className.baseVal).to.contain('ct-series-b'); + expect(svgSeries2[2].className.baseVal).to.contain('ct-series-c'); + }); + + it('should update the legend item classes', function() { + var seriesA = chart.container.querySelector('ul.ct-legend > .ct-series-0'); + var seriesB = chart.container.querySelector('ul.ct-legend > .ct-series-1'); + + // The first click should hide the corresponding series. + click(seriesB); + var legendItems = chart.container.querySelectorAll('ul.ct-legend > li'); + expect(legendItems[0].className).to.equal('ct-series-0'); + expect(legendItems[1].className).to.equal('ct-series-1 inactive'); + + // A second click should show the corresponding series again. + click(seriesB); + var inactiveItem = chart.container.querySelectorAll('ul.ct-legend > li.inactive'); + expect(inactiveItem.length).to.equal(0); + + // A click in the last active series should set all series active again. + click(seriesA); + var legendItems = chart.container.querySelectorAll('ul.ct-legend > li'); + expect(legendItems[0].className).to.equal('ct-series-0 inactive'); + expect(legendItems[1].className).to.equal('ct-series-1'); + click(seriesB); + var inactiveItem = chart.container.querySelectorAll('ul.ct-legend > li.inactive'); + expect(inactiveItem.length).to.equal(0); + + }); + + it('should call a function after a click on the legend item', function() { + var seriesB = chart.container.querySelector('ul.ct-legend > .ct-series-1'); + + click(seriesB); + expect(chart.legendClicked).to.equal(true); + + //Clicking on an inactive series should also call the function. + chart.legendClicked = false; + click(seriesB); + expect(chart.legendClicked).to.equal(true); + }); + }); + describe('clickable for a pie', function() { before(function(done) { chart = generateChart('Pie', chartDataPie, {