Initial Commit
--- /dev/null
+++ b/ckanext/dga-stats/__init__.py
@@ -1,1 +1,2 @@
+# empty file needed for pylons to find templates in this directory
Binary files /dev/null and b/ckanext/dga-stats/__init__.pyc differ
--- /dev/null
+++ b/ckanext/dga-stats/controller.py
@@ -1,1 +1,51 @@
+import ckan.plugins as p
+from ckan.lib.base import BaseController, config
+import stats as stats_lib
+import ckan.lib.helpers as h
+class StatsController(BaseController):
+
+ def index(self):
+ c = p.toolkit.c
+ stats = stats_lib.Stats()
+ rev_stats = stats_lib.RevisionStats()
+ c.top_rated_packages = stats.top_rated_packages()
+ c.most_edited_packages = stats.most_edited_packages()
+ c.largest_groups = stats.largest_groups()
+ c.top_tags = stats.top_tags()
+ c.top_package_owners = stats.top_package_owners()
+ c.new_packages_by_week = rev_stats.get_by_week('new_packages')
+ c.deleted_packages_by_week = rev_stats.get_by_week('deleted_packages')
+ c.num_packages_by_week = rev_stats.get_num_packages_by_week()
+ c.package_revisions_by_week = rev_stats.get_by_week('package_revisions')
+
+ # Used in the legacy CKAN templates.
+ c.packages_by_week = []
+
+ # Used in new CKAN templates gives more control to the templates for formatting.
+ c.raw_packages_by_week = []
+ for week_date, num_packages, cumulative_num_packages in c.num_packages_by_week:
+ c.packages_by_week.append('[new Date(%s), %s]' % (week_date.replace('-', ','), cumulative_num_packages))
+ c.raw_packages_by_week.append({'date': h.date_str_to_datetime(week_date), 'total_packages': cumulative_num_packages})
+
+ c.all_package_revisions = []
+ c.raw_all_package_revisions = []
+ for week_date, revs, num_revisions, cumulative_num_revisions in c.package_revisions_by_week:
+ c.all_package_revisions.append('[new Date(%s), %s]' % (week_date.replace('-', ','), num_revisions))
+ c.raw_all_package_revisions.append({'date': h.date_str_to_datetime(week_date), 'total_revisions': num_revisions})
+
+ c.new_datasets = []
+ c.raw_new_datasets = []
+ for week_date, pkgs, num_packages, cumulative_num_packages in c.new_packages_by_week:
+ c.new_datasets.append('[new Date(%s), %s]' % (week_date.replace('-', ','), num_packages))
+ c.raw_new_datasets.append({'date': h.date_str_to_datetime(week_date), 'new_packages': num_packages})
+
+ return p.toolkit.render('ckanext/stats/index.html')
+
+ def leaderboard(self, id=None):
+ c = p.toolkit.c
+ c.solr_core_url = config.get('ckanext.stats.solr_core_url',
+ 'http://solr.okfn.org/solr/ckan')
+ return p.toolkit.render('ckanext/stats/leaderboard.html')
+
+
--- /dev/null
+++ b/ckanext/dga-stats/plugin.py
@@ -1,1 +1,28 @@
+from logging import getLogger
+import ckan.plugins as p
+
+log = getLogger(__name__)
+
+class StatsPlugin(p.SingletonPlugin):
+ '''Stats plugin.'''
+
+ p.implements(p.IRoutes, inherit=True)
+ p.implements(p.IConfigurer, inherit=True)
+
+ def after_map(self, map):
+ map.connect('stats', '/stats',
+ controller='ckanext.stats.controller:StatsController',
+ action='index')
+ map.connect('stats_action', '/stats/{action}',
+ controller='ckanext.stats.controller:StatsController')
+ return map
+
+ def update_config(self, config):
+ templates = 'templates'
+ if p.toolkit.asbool(config.get('ckan.legacy_templates', False)):
+ templates = 'templates_legacy'
+ p.toolkit.add_template_directory(config, templates)
+ p.toolkit.add_public_directory(config, 'public')
+ p.toolkit.add_resource('public/ckanext/stats', 'ckanext_stats')
+
Binary files /dev/null and b/ckanext/dga-stats/plugin.pyc differ
--- /dev/null
+++ b/ckanext/dga-stats/public/.gitignore
@@ -1,1 +1,3 @@
+**.min.js
+**.min.css
--- /dev/null
+++ b/ckanext/dga-stats/public/__init__.py
@@ -1,1 +1,8 @@
+# this is a namespace package
+try:
+ import pkg_resources
+ pkg_resources.declare_namespace(__name__)
+except ImportError:
+ import pkgutil
+ __path__ = pkgutil.extend_path(__path__, __name__)
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/__init__.py
@@ -1,1 +1,8 @@
+# this is a namespace package
+try:
+ import pkg_resources
+ pkg_resources.declare_namespace(__name__)
+except ImportError:
+ import pkgutil
+ __path__ = pkgutil.extend_path(__path__, __name__)
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/__init__.py
@@ -1,1 +1,8 @@
+# this is a namespace package
+try:
+ import pkg_resources
+ pkg_resources.declare_namespace(__name__)
+except ImportError:
+ import pkgutil
+ __path__ = pkgutil.extend_path(__path__, __name__)
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/app.js
@@ -1,1 +1,60 @@
+jQuery(document).ready(function($) {
+ $('form').submit(function(e) {
+ e.preventDefault();
+ attribute = $('#form-attribute').val();
+ loadSolr(attribute);
+ })
+ // default! (also in html)
+ loadSolr('tags');
+ function loadSolr(attribute) {
+ var url = solrCoreUrl + '/select?indent=on&wt=json&facet=true&rows=0&indent=true&facet.mincount=1&facet.limit=30&q=*:*&facet.field=' + attribute;
+ function handleSolr(data) {
+ var results = [];
+ ourdata = data.facet_counts.facet_fields[attribute];
+ var newrow = {};
+ for (ii in ourdata) {
+ if (ii % 2 == 0) {
+ newrow.name = ourdata[ii];
+ if (!newrow.name) {
+ newrow.name = '[Not Specified]';
+ }
+ } else {
+ newrow.count = ourdata[ii];
+ results.push(newrow);
+ newrow = {};
+ }
+ }
+ display(results);
+ }
+
+ $.ajax({
+ url: url,
+ success: handleSolr,
+ dataType: 'jsonp',
+ jsonp: 'json.wrf'
+ });
+ }
+
+ function display(results) {
+ var list = $('#category-counts');
+ list.html('');
+ if (results.length == 0) {
+ return
+ }
+ var maximum = results[0]['count'];
+ for(ii in results) {
+ maximum = Math.max(maximum, results[ii]['count']);
+ }
+
+ $.each(results, function(idx, row) {
+ var newentry = $('<li></li>');
+ newentry.append($('<a href="#">' + row['name'] + '</a>'));
+ newentry.append($('<span class="count">' + row['count'] + '</a>'));
+ var percent = 100 * row['count'] / maximum;
+ newentry.append($('<span class="index" style="width: ' + percent + '%"></span>'));
+ list.append(newentry);
+ });
+ }
+});
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/css/stats.css
@@ -1,1 +1,17 @@
+.tab-content h2 {
+ margin-bottom: 12px;
+}
+.js .tab-content {
+ padding-top: 20px;
+ padding-bottom: 20px;
+ margin-top: 0;
+}
+
+.module-plot-canvas {
+ display: block;
+ width: 650px;
+ height: 300px;
+ margin: 20px 0;
+}
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/demo.html
@@ -1,1 +1,26 @@
+<html>
+ <head>
+ <script type="text/javascript">
+ var solrCoreUrl = 'http://solr.okfn.org/solr/ckan';
+ </script>
+ <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js"></script>
+ <script type="text/javascript" src="app.js"></script>
+ <link type="text/css" rel="stylesheet" media="all" href="style.css" />
+ </head>
+ <body>
+ <h1>CKAN Dataset Leaderboard</h1>
+ <p>Choose a dataset attribute and find out which categories in that area have the most datasets. E.g. tags, groups, license, res_format, country.</p>
+ <form>
+ <label for="category">Choose area</label>
+ <input type="text" value="tags" name="attribute" id="form-attribute" />
+ <input type="submit" value="Dataset Counts »" name="submit" />
+ </form>
+
+ <div class="category-counts">
+ <ul class="chartlist" id="category-counts">
+ </ul>
+ </div>
+ </body>
+</html>
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/javascript/modules/plot.js
@@ -1,1 +1,210 @@
-
+/* A quick module for generating flot charts from an HTML table. Options can
+ * be passed directly to flot using the data-module-* attributes. The tables
+ * are currently expected to be marked up as follows:
+ *
+ * <table data-module="plot">
+ * <thead>
+ * <tr>
+ * <th>X Axis</th>
+ * <th>Series A Legend</th>
+ * <th>Series B Legend</th>
+ * </tr>
+ * </thead>
+ * <tbody>
+ * <tr>
+ * <th>X Value</th>
+ * <td>Series A Y Value</td>
+ * <td>Series B Y Value</td>
+ * </tr>
+ * ...
+ * </tbody>
+ * </table>
+ *
+ * Points are pulled out of the th/td elements using innerHTML or by looking
+ * for a data-value attribute. This is useful when a more readable value
+ * needs to be used in the elements contents (eg. dates). A data-type attribute
+ * can also be applied to parse the value. Only data-type="date" is currently
+ * supported and expects data-value to be a unix timestamp.
+ */
+this.ckan.module('plot', function (jQuery, _) {
+ return {
+ /* Holds the jQuery.plot() object when created */
+ graph: null,
+
+ /* Holds the canvas container when created */
+ canvas: null,
+
+ /* Default options */
+ options: {
+ xaxis: {},
+ yaxis: {},
+ legend: {position: 'nw'},
+ colors: ['#ffcc33', '#ff8844']
+ },
+
+ /* Sets up the canvas element and parses the table.
+ *
+ * Returns nothing.
+ */
+ initialize: function () {
+ jQuery.proxyAll(this, /_on/);
+
+ if (!this.el.is('table')) {
+ throw new Error('CKAN module plot can only be called on table elements');
+ }
+
+ this.setupCanvas();
+
+ // Because the canvas doesn't render correctly unless visible we must
+ // listen for events that reveal the canvas and then try and re-render.
+ // Currently the most common of these is the "shown" event triggered by
+ // the tabs plugin.
+ this.sandbox.body.on("shown", this._onShown);
+ this.data = this.parseTable(this.el);
+
+ this.draw();
+ },
+
+ /* Removes event handlers when the module is removed from the DOM.
+ *
+ * Returns nothing.
+ */
+ teardown: function () {
+ this.sandbox.body.off("shown", this._onShown);
+ },
+
+ /* Creates the canvas wrapper and removes the table from the document.
+ *
+ * Returns nothing.
+ */
+ setupCanvas: function () {
+ this.canvas = jQuery('<div class="module-plot-canvas">');
+ this.el.replaceWith(this.canvas);
+ },
+
+ /* Attempts to draw the chart if the canvas is visible. If not visible the
+ * graph does not render correctly. So we keep trying until it is.
+ *
+ * Examples
+ *
+ * module.draw();
+ *
+ * Returns nothing.
+ */
+ draw: function () {
+ if (!this.drawn && this.canvas.is(':visible')) {
+ this.graph = jQuery.plot(this.canvas, this.data, this.options);
+ }
+ },
+
+ /* Parses an HTML table element to build the data array for the chart.
+ * The thead element provides the axis and labels for the series. The
+ * first column in the tbody is used for the x-axis and subsequent
+ * columns are the series.
+ *
+ * table - A table element to parse.
+ *
+ * Examples
+ *
+ * module.parseTable(module.el);
+ *
+ * Returns data object suitable for use in jQuery.plot().
+ */
+ parseTable: function (table) {
+ var data = [];
+ var _this = this;
+
+ var headings = table.find('thead tr:first th').map(function () {
+ return this.innerHTML;
+ });
+
+ table.find('tbody tr').each(function (row) {
+ var element = jQuery(this);
+ var x = [];
+
+ x[row] = _this.getValue(element.find('th'));
+
+ element.find('td').each(function (series) {
+ var value = _this.getValue(this);
+ var label = headings[series + 1];
+
+ data[series] = data[series] || {data: [], label: label};
+ data[series].data[row] = [x[row], value];
+ });
+ });
+
+ return data;
+ },
+
+ /* Retrieves the value from a td/th element. This first looks for a
+ * data-value attribute on the element otherwise uses the element
+ * text contents.
+ *
+ * A data-type attribute can also be provided to tell the module how
+ * to deal with the element. By default we let jQuery.data() handle
+ * the parsing but this can provide additional data. See .parseValue()
+ * for more info.
+ *
+ * cell - An element to extract a value from.
+ *
+ * Examples
+ *
+ * var element = jQuery('<td data-value="10">Ten</td>');
+ * module.getValue(element); //=> 10
+ *
+ * var element = jQuery('<td>20</td>');
+ * module.getValue(element); //=> 20
+ *
+ * var element = jQuery('<td data-type="date">1343747094</td>');
+ * module.getValue(element); //=> <Date Tue Jul 31 2012 16:04:54 GMT+0100 (BST)>
+ *
+ * Returns the parsed value.
+ */
+ getValue: function (cell) {
+ var item = cell instanceof jQuery ? cell : jQuery(cell);
+ var type = item.data('type') || 'string';
+ var value = item.data('value') || item.text();
+ return this.parseValue(value, type);
+ },
+
+ /* Provides the ability to further format a value.
+ *
+ * If date is provided as a type then it expects value to be a unix
+ * timestamp in seconds.
+ *
+ * value - The value extracted from the element.
+ * type - A type string, currently only supports "date".
+ *
+ * Examples
+ *
+ * module.parseValue(10); // => 10
+ * module.parseValue("cat"); // => "cat"
+ * module.parseValue(1343747094, 'date'); // => <Date Tue Jul 31 2012 16:04:54 GMT+0100 (BST)>
+ *
+ * Returns the parsed value.
+ */
+ parseValue: function (value, type) {
+ if (type === 'date') {
+ value = new Date(parseInt(value, 10) * 1000);
+ if (!value) {
+ value = 0;
+ }
+ }
+ return value;
+ },
+
+ /* Event handler for when tabs are toggled. Determines if the canvas
+ * resides in the shown element and attempts to re-render.
+ *
+ * event - The shown event object.
+ *
+ * Returns nothing.
+ */
+ _onShown: function (event) {
+ if (!this.drawn && jQuery.contains(jQuery(event.target.hash)[0], this.canvas[0])) {
+ this.draw();
+ }
+ }
+ };
+});
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/javascript/modules/stats-nav.js
@@ -1,1 +1,37 @@
+/* Quick module to enhance the Bootstrap tags plug-in to update the url
+ * hash when a tab changes to allow the user to bookmark the page.
+ *
+ * Each tab id must use a prefix which which will be stripped from the hash.
+ * This is to prevent the page jumping when the hash fragment changes.
+ *
+ * prefix - The prefix used on the ids.
+ *
+ */
+this.ckan.module('stats-nav', {
+ /* An options object */
+ options: {
+ prefix: 'stats-'
+ },
+ /* Initializes the module and sets up event listeners.
+ *
+ * Returns nothing.
+ */
+ initialize: function () {
+ var location = this.sandbox.location;
+ var prefix = this.options.prefix;
+ var hash = location.hash.slice(1);
+ var selected = this.$('[href^=#' + prefix + hash + ']');
+
+ // Update the hash fragment when the tab changes.
+ this.el.on('shown', function (event) {
+ location.hash = event.target.hash.slice(prefix.length + 1);
+ });
+
+ // Show the current tab if the location provides one.
+ if (selected.length) {
+ selected.tab('show');
+ }
+ }
+});
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/resource.config
@@ -1,1 +1,13 @@
+[IE conditional]
+lte IE 8 = vendor/excanvas.js
+
+[groups]
+
+stats =
+ css/stats.css
+ vendor/excanvas.js
+ vendor/jquery.flot.js
+ javascript/modules/plot.js
+ javascript/modules/stats-nav.js
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/style.css
@@ -1,1 +1,60 @@
+div.category-counts {
+}
+div.category-counts-over-time {
+ clear: both;
+}
+
+/***************************
+ * CHART LISTS
+ **************************/
+
+.chartlist {
+ float: left;
+ border-top: 1px solid #EEE;
+ width: 90%;
+ padding-left: 0;
+ margin-left: 0;
+}
+
+.chartlist li {
+ position: relative;
+ display: block;
+ border-bottom: 1px solid #EEE;
+ _zoom: 1;
+}
+.chartlist li a {
+ display: block;
+ padding: 0.4em 4.5em 0.4em 0.5em;
+ position: relative;
+ z-index: 2;
+}
+.chartlist .count {
+ display: block;
+ position: absolute;
+ top: 0;
+ right: 0;
+ margin: 0 0.3em;
+ text-align: right;
+ color: #999;
+ font-weight: bold;
+ font-size: 0.875em;
+ line-height: 2em;
+ z-index: 999;
+}
+.chartlist .index {
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: #B8E4F5;
+ text-indent: -9999px;
+ overflow: hidden;
+ line-height: 2em;
+}
+.chartlist li:hover {
+ background: #EFEFEF;
+}
+
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/test/fixtures/table.html
@@ -1,1 +1,31 @@
+<table data-module="plot">
+ <thead>
+ <tr>
+ <th>X Axis</th>
+ <th>Series A Legend</th>
+ <th>Series B Legend</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <!-- This is the x value for each series -->
+ <th data-type="date" data-value="1176073200">Apr 09, 2007</th>
+ <!-- This is the y value for series a -->
+ <td>20</td>
+ <!-- This is the y value for series b -->
+ <td>7</td>
+ </tr>
+ <tr>
+ <th data-type="date" data-value="1176678000">Apr 16, 2007</th>
+ <td>12</td>
+ <td>6</td>
+ </tr>
+ <tr>
+ <th data-type="date" data-value="1177282800">Apr 23, 2007</th>
+ <td>27</td>
+ <td>12</td>
+ </tr>
+ </tbody>
+</table>
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/test/index.html
@@ -1,1 +1,60 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Mocha Tests</title>
+ <link rel="stylesheet" href="../../../base/test/vendor/mocha.css" />
+ </head>
+ <body>
+ <div id="mocha"></div>
+ <div id="fixture" style="position: absolute; top: -9999px; left: -9999px"></div>
+ <!-- Test Runner -->
+ <script src="../../../base/test/vendor/sinon.js"></script>
+ <script src="../../../base/test/vendor/mocha.js"></script>
+ <script src="../../../base/test/vendor/chai.js"></script>
+ <script>
+ mocha.setup('bdd');
+ var assert = chai.assert;
+
+ // Export sinon.assert methods onto assert.
+ sinon.assert.expose(assert, {prefix: ''});
+
+ var ckan = {ENV: 'testing'};
+ </script>
+
+ <!-- Source -->
+ <script src="../../../base/vendor/jed.js"></script>
+ <script src="../../../base/vendor/jquery.js"></script>
+ <script src="../../../base/vendor/bootstrap/js/bootstrap-transition.js"></script>
+ <script src="../../../base/vendor/bootstrap/js/bootstrap-alert.js"></script>
+ <script src="../../../base/javascript/plugins/jquery.inherit.js"></script>
+ <script src="../../../base/javascript/plugins/jquery.proxy-all.js"></script>
+ <script src="../../../base/javascript/sandbox.js"></script>
+ <script src="../../../base/javascript/module.js"></script>
+ <script src="../../../base/javascript/pubsub.js"></script>
+ <script src="../../../base/javascript/client.js"></script>
+ <script src="../../../base/javascript/notify.js"></script>
+ <script src="../../../base/javascript/i18n.js"></script>
+ <script src="../../../base/javascript/main.js"></script>
+ <script src="../javascript/modules/plot.js"></script>
+ <script src="../javascript/modules/stats-nav.js"></script>
+
+ <!-- Suite -->
+ <script src="./spec/modules/plot.spec.js"></script>
+ <script src="./spec/modules/stats-nav.spec.js"></script>
+
+ <script>
+ beforeEach(function () {
+ this.fixture = jQuery('#fixture').empty();
+ });
+
+ afterEach(function () {
+ this.fixture.empty();
+ });
+
+ mocha.run().globals(['ckan', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval']);
+ </script>
+ </body>
+</html>
+
--- /dev/null
+++ b/ckanext/dga-stats/public/ckanext/stats/test/spec/modules/plot.spec.js
@@ -1,1 +1,137 @@
+/*globals describe before beforeEach afterEach it assert sinon ckan jQuery */
+describe('ckan.module.PlotModule()', function () {
+ var PlotModule = ckan.module.registry['plot'];
+ before(function (done) {
+ var _this = this;
+
+ jQuery.get('./fixtures/table.html', function (html) {
+ _this.template = html;
+ done();
+ });
+ });
+
+ beforeEach(function () {
+ this.el = jQuery(this.template).appendTo(this.fixture);
+ this.sandbox = ckan.sandbox();
+ this.sandbox.body = this.fixture;
+ this.module = new PlotModule(this.el, {}, this.sandbox);
+ });
+
+ afterEach(function () {
+ this.module.teardown();
+ });
+
+ describe('.initialize()', function () {
+ it('should setup the canvas element', function () {
+ var target = sinon.stub(this.module, 'setupCanvas', this.module.setupCanvas);
+
+ this.module.initialize();
+ assert.called(target);
+ });
+
+ it('should draw the graph', function () {
+ var target = sinon.stub(this.module, 'draw');
+
+ this.module.initialize();
+ assert.called(target);
+ });
+
+ it('should listen for "shown" events on the body', function () {
+ var target = sinon.stub(this.sandbox.body, 'on');
+
+ this.module.initialize();
+ assert.called(target);
+ assert.calledWith(target, "shown", this.module._onShown);
+ });
+ });
+
+ describe('.teardown()', function () {
+ it('should remove "shown" listeners from the body', function () {
+ var target = sinon.stub(this.sandbox.body, 'off');
+
+ this.module.teardown();
+ assert.called(target);
+ assert.calledWith(target, "shown", this.module._onShown);
+ });
+ });
+
+ describe('.setupCanvas()', function () {
+ it('should create the .canvas element', function () {
+ this.module.setupCanvas();
+
+ assert.isDefined(this.module.canvas);
+ assert.isDefined(this.module.canvas.is('div'));
+ });
+
+ it('should replace the .el with the .canvas', function () {
+ this.module.setupCanvas();
+ assert.ok(jQuery.contains(this.sandbox.body[0], this.module.canvas[0]));
+ });
+ });
+
+ describe('.draw()', function () {
+ beforeEach(function () {
+ this.plot = {};
+ this.module.canvas = jQuery('<div />').appendTo(this.fixture);
+ jQuery.plot = sinon.stub().returns(this.plot);
+ });
+
+ it('should call jQuery.plot() if the canvas is visible', function () {
+ this.module.draw();
+
+ assert.called(jQuery.plot);
+ assert.calledWith(jQuery.plot, this.module.canvas, this.module.data, this.module.options);
+ });
+
+ it('should assign the .graph property', function () {
+ this.module.draw();
+ assert.strictEqual(this.module.graph, this.plot);
+ });
+
+ it('should not call jQuery.plot() if the canvas is not visible', function () {
+ this.module.canvas.hide();
+ this.module.draw();
+
+ assert.notCalled(jQuery.plot);
+ });
+ });
+
+ describe('.parseTable(table)', function () {
+ it('should parse the contents of the provided table', function () {
+ var target = this.module.parseTable(this.module.el);
+
+ assert.deepEqual(target, [{
+ label: 'Series A Legend',
+ data: [
+