From d5fee94d153233011ab0d31652dc6f1bc551afe2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Thu, 28 Mar 2019 09:58:54 +0100
Subject: [PATCH] dataviz: render graph locally using pygal (#20771)

---
 README                                        |   7 +
 combo/apps/dataviz/forms.py                   |  27 +-
 .../migrations/0010_auto_20190328_1111.py     |  45 +++
 combo/apps/dataviz/models.py                  | 157 +++++++-
 .../apps/dataviz/static/js/pygal-tooltips.js  | 353 ++++++++++++++++++
 .../dataviz/templates/combo/chartngcell.html  |  24 ++
 .../templates/combo/chartngcell_form.html     |   8 +
 combo/apps/dataviz/urls.py                    |   4 +-
 combo/apps/dataviz/views.py                   |  18 +-
 combo/settings.py                             |   1 +
 debian/control                                |   4 +-
 setup.py                                      |   2 +
 tests/settings.py                             |   1 +
 tests/test_dataviz.py                         | 216 ++++++++++-
 14 files changed, 858 insertions(+), 9 deletions(-)
 create mode 100644 combo/apps/dataviz/migrations/0010_auto_20190328_1111.py
 create mode 100644 combo/apps/dataviz/static/js/pygal-tooltips.js
 create mode 100644 combo/apps/dataviz/templates/combo/chartngcell.html
 create mode 100644 combo/apps/dataviz/templates/combo/chartngcell_form.html

diff --git a/README b/README
index 464a572d..06ba0ffe 100644
--- a/README
+++ b/README
@@ -120,3 +120,10 @@ Gauge.js
   License: MIT
   Comment:
    From http://bernii.github.io/gauge.js/
+
+Pygal.tooltip.js
+  Files: combo/apps/dataviz/static/js/pygal.tooltip.js
+  Copyright: 2015, Florian Mounier Kozea
+  License: LGPL-3+
+  Comment:
+   From https://github.com/Kozea/pygal.js/
diff --git a/combo/apps/dataviz/forms.py b/combo/apps/dataviz/forms.py
index a137929f..ce6d92ae 100644
--- a/combo/apps/dataviz/forms.py
+++ b/combo/apps/dataviz/forms.py
@@ -19,7 +19,7 @@ from django.conf import settings
 
 from combo.utils import requests
 
-from .models import ChartCell
+from .models import ChartCell, ChartNgCell
 
 
 class ChartForm(forms.ModelForm):
@@ -37,3 +37,28 @@ class ChartForm(forms.ModelForm):
             available_charts.extend([(x['path'], x['name']) for x in result])
         available_charts.sort(key=lambda x: x[1])
         self.fields['url'].widget = forms.Select(choices=available_charts)
+
+
+class ChartNgForm(forms.ModelForm):
+    class Meta:
+        model = ChartNgCell
+        fields = ('title', 'data_reference', 'chart_type', 'height')
+
+    def __init__(self, *args, **kwargs):
+        super(ChartNgForm, self).__init__(*args, **kwargs)
+        data_references = []
+        bijoe_sites = settings.KNOWN_SERVICES.get('bijoe').items()
+        for site_key, site_dict in bijoe_sites:
+            result = requests.get('/visualization/json/',
+                    remote_service=site_dict, without_user=True,
+                    headers={'accept': 'application/json'}).json()
+            if len(bijoe_sites) > 1:
+                label_prefix = _('%s: ') % site_dict.get('title')
+            else:
+                label_prefix = ''
+            data_references.extend([
+                ('%s:%s' % (site_key, x['slug']), '%s%s' % (label_prefix, x['name']))
+                for x in result])
+
+        data_references.sort(key=lambda x: x[1])
+        self.fields['data_reference'].widget = forms.Select(choices=data_references)
diff --git a/combo/apps/dataviz/migrations/0010_auto_20190328_1111.py b/combo/apps/dataviz/migrations/0010_auto_20190328_1111.py
new file mode 100644
index 00000000..3c4f723f
--- /dev/null
+++ b/combo/apps/dataviz/migrations/0010_auto_20190328_1111.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.12 on 2019-03-28 10:11
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import jsonfield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('data', '0036_page_sub_slug'),
+        ('dataviz', '0009_auto_20190617_1214'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ChartNgCell',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('placeholder', models.CharField(max_length=20)),
+                ('order', models.PositiveIntegerField()),
+                ('slug', models.SlugField(blank=True, verbose_name='Slug')),
+                ('extra_css_class', models.CharField(blank=True, max_length=100, verbose_name='Extra classes for CSS styling')),
+                ('public', models.BooleanField(default=True, verbose_name='Public')),
+                ('restricted_to_unlogged', models.BooleanField(default=False, verbose_name='Restrict to unlogged users')),
+                ('last_update_timestamp', models.DateTimeField(auto_now=True)),
+                ('data_reference', models.CharField(max_length=150, verbose_name='Data')),
+                ('title', models.CharField(blank=True, max_length=150, verbose_name='Title')),
+                ('cached_json', jsonfield.fields.JSONField(blank=True, default=dict)),
+                ('chart_type', models.CharField(choices=[(b'bar', 'Bar'), (b'horizontal-bar', 'Horizontal Bar'), (b'stacked-bar', 'Stacked Bar'), (b'line', 'Line'), (b'pie', 'Pie'), (b'dot', 'Dot'), (b'table', 'Table')], default=b'bar', max_length=20, verbose_name='Chart Type')),
+                ('height', models.CharField(choices=[(b'150', 'Short (150px)'), (b'250', 'Average (250px)'), (b'350', 'Tall (350px)')], default=b'250', max_length=20, verbose_name='Height')),
+                ('groups', models.ManyToManyField(blank=True, to='auth.Group', verbose_name='Groups')),
+                ('page', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='data.Page')),
+            ],
+            options={
+                'verbose_name': 'Chart',
+            },
+        ),
+        migrations.AlterModelOptions(
+            name='chartcell',
+            options={'verbose_name': 'Chart (legacy)'},
+        ),
+    ]
diff --git a/combo/apps/dataviz/models.py b/combo/apps/dataviz/models.py
index 20fe8412..2245eeab 100644
--- a/combo/apps/dataviz/models.py
+++ b/combo/apps/dataviz/models.py
@@ -14,14 +14,20 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import copy
+import os
+
 from django.core.urlresolvers import reverse
 from django.db import models
 from django.utils.translation import ugettext_lazy as _
 from django.conf import settings
 
+from jsonfield import JSONField
+import pygal
+
 from combo.data.models import CellBase
 from combo.data.library import register_cell_class
-from combo.utils import get_templated_url
+from combo.utils import get_templated_url, requests
 
 
 @register_cell_class
@@ -68,11 +74,11 @@ class ChartCell(CellBase):
     url = models.URLField(_('URL'), max_length=250, blank=True, null=True)
 
     class Meta:
-        verbose_name = _('Chart')
+        verbose_name = _('Chart (legacy)')
 
     @classmethod
     def is_enabled(self):
-        return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
+        return settings.LEGACY_CHART_CELL_ENABLED and hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
 
     def get_default_form_class(self):
         from .forms import ChartForm
@@ -88,3 +94,148 @@ class ChartCell(CellBase):
         context['title'] = self.title
         context['url'] = self.url
         return context
+
+
+@register_cell_class
+class ChartNgCell(CellBase):
+    data_reference = models.CharField(_('Data'), max_length=150)
+    title = models.CharField(_('Title'), max_length=150, blank=True)
+    cached_json = JSONField(blank=True)
+    chart_type = models.CharField(_('Chart Type'), max_length=20, default='bar',
+            choices=(
+                ('bar', _('Bar')),
+                ('horizontal-bar', _('Horizontal Bar')),
+                ('stacked-bar', _('Stacked Bar')),
+                ('line', _('Line')),
+                ('pie', _('Pie')),
+                ('dot', _('Dot')),
+                ('table', _('Table')),
+            ))
+
+    height = models.CharField(_('Height'), max_length=20, default='250',
+            choices=(
+                ('150', _('Short (150px)')),
+                ('250', _('Average (250px)')),
+                ('350', _('Tall (350px)')),
+            ))
+
+    manager_form_template = 'combo/chartngcell_form.html'
+
+    class Meta:
+        verbose_name = _('Chart')
+
+    @classmethod
+    def is_enabled(self):
+        return hasattr(settings, 'KNOWN_SERVICES') and settings.KNOWN_SERVICES.get('bijoe')
+
+    def get_default_form_class(self):
+        from .forms import ChartNgForm
+        return ChartNgForm
+
+    def get_additional_label(self):
+        return self.title
+
+    def save(self, *args, **kwargs):
+        if self.data_reference:
+            site_key, visualization_slug = self.data_reference.split(':')
+            site_dict = settings.KNOWN_SERVICES['bijoe'][site_key]
+            response_json = requests.get('/visualization/json/',
+                    remote_service=site_dict, without_user=True,
+                    headers={'accept': 'application/json'}).json()
+            if isinstance(response_json, dict):
+                # forward compatibility with possible API change
+                response_json = response_json.get('data')
+            for visualization in response_json:
+                slug = visualization.get('slug')
+                if slug == visualization_slug:
+                    self.cached_json = visualization
+        return super(ChartNgCell, self).save(*args, **kwargs)
+
+    def get_cell_extra_context(self, context):
+        ctx = super(ChartNgCell, self).get_cell_extra_context(context)
+        if self.chart_type == 'table':
+            chart = self.get_chart(raise_if_not_cached=not(context.get('synchronous')))
+            ctx['table'] = chart.render_table(
+                transpose=bool(chart.axis_count == 2),
+            )
+        return ctx
+
+    def get_chart(self, width=None, height=None, raise_if_not_cached=False):
+        response = requests.get(
+                self.cached_json['data-url'],
+                cache_duration=300,
+                raise_if_not_cached=raise_if_not_cached).json()
+
+        style = pygal.style.DefaultStyle(
+            font_family='OpenSans, sans-serif',
+            background='transparent')
+
+        chart = {
+            'bar': pygal.Bar,
+            'horizontal-bar': pygal.HorizontalBar,
+            'stacked-bar': pygal.StackedBar,
+            'line': pygal.Line,
+            'pie': pygal.Pie,
+            'dot': pygal.Dot,
+            'table': pygal.Bar,
+            }[self.chart_type](config=pygal.Config(style=copy.copy(style)))
+
+        # normalize axis to have a fake axis when there are no dimensions and
+        # always a x axis when there is a single dimension.
+        x_labels = response['axis'].get('x_labels') or []
+        y_labels = response['axis'].get('y_labels') or []
+        data = response['data']
+        if not x_labels and not y_labels:  # unidata
+            x_labels = ['']
+            y_labels = ['']
+            data = [data]
+            chart.axis_count = 0
+        elif not x_labels:
+            x_labels = y_labels
+            y_labels = ['']
+            chart.axis_count = 1
+        elif not y_labels:
+            y_labels = ['']
+            chart.axis_count = 1
+        else:
+            chart.axis_count = 2
+
+        chart.config.margin = 0
+        if width:
+            chart.config.width = width
+        if height:
+            chart.config.height = height
+        if width or height:
+            chart.config.explicit_size = True
+        chart.config.js = [os.path.join(settings.STATIC_URL, 'js/pygal-tooltips.js')]
+        chart.x_labels = x_labels
+
+        chart.show_legend = bool(len(response['axis']) > 1)
+        # matplotlib tab10 palette
+        chart.config.style.colors = (
+                '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728',
+                '#9467bd', '#8c564b', '#e377c2', '#7f7f7f',
+                '#bcbd22', '#17becf')
+
+        if self.chart_type == 'dot':
+            chart.show_legend = False
+            # use a single colour for dots
+            chart.config.style.colors = ('#1f77b4',) * len(x_labels)
+
+        if self.chart_type != 'pie':
+            for i, serie_label in enumerate(y_labels):
+                if chart.axis_count < 2:
+                    values = data
+                else:
+                    values = [data[i][j] for j in range(len(x_labels))]
+                chart.add(serie_label, values)
+        else:
+            # pie, create a serie by data, to get different colours
+            values = data
+            for label, value in zip(x_labels, values):
+                if not value:
+                    continue
+                chart.add(label, value)
+            chart.show_legend = True
+
+        return chart
diff --git a/combo/apps/dataviz/static/js/pygal-tooltips.js b/combo/apps/dataviz/static/js/pygal-tooltips.js
new file mode 100644
index 00000000..9e2a87e6
--- /dev/null
+++ b/combo/apps/dataviz/static/js/pygal-tooltips.js
@@ -0,0 +1,353 @@
+(function() {
+  var $, get_translation, init, init_svg, matches, padding, r_translation, sibl, svg_ns, tooltip_timeout, xlink_ns;
+
+  svg_ns = 'http://www.w3.org/2000/svg';
+
+  xlink_ns = 'http://www.w3.org/1999/xlink';
+
+  $ = function(sel, ctx) {
+    if (ctx == null) {
+      ctx = null;
+    }
+    ctx = ctx || document;
+    return Array.prototype.slice.call(ctx.querySelectorAll(sel), 0).filter(function(e) {
+      return e !== ctx;
+    });
+  };
+
+  matches = function(el, selector) {
+    return (el.matches || el.matchesSelector || el.msMatchesSelector || el.mozMatchesSelector || el.webkitMatchesSelector || el.oMatchesSelector).call(el, selector);
+  };
+
+  sibl = function(el, match) {
+    if (match == null) {
+      match = null;
+    }
+    return Array.prototype.filter.call(el.parentElement.children, function(child) {
+      return child !== el && (!match || matches(child, match));
+    });
+  };
+
+  Array.prototype.one = function() {
+    return this.length > 0 && this[0] || {};
+  };
+
+  padding = 5;
+
+  tooltip_timeout = null;
+
+  r_translation = /translate\((\d+)[ ,]+(\d+)\)/;
+
+  get_translation = function(el) {
+    return (r_translation.exec(el.getAttribute('transform')) || []).slice(1).map(function(x) {
+      return +x;
+    });
+  };
+
+  init = function(ctx) {
+    var bbox, box, config, el, graph, inner_svg, num, parent, tooltip, tooltip_el, tt, uid, untooltip, xconvert, yconvert, _i, _j, _k, _len, _len1, _len2, _ref, _ref1, _ref2, _ref3;
+    if ($('svg', ctx).length) {
+      inner_svg = $('svg', ctx).one();
+      parent = inner_svg.parentElement;
+      box = inner_svg.viewBox.baseVal;
+      bbox = parent.getBBox();
+      xconvert = function(x) {
+        return ((x - box.x) / box.width) * bbox.width;
+      };
+      yconvert = function(y) {
+        return ((y - box.y) / box.height) * bbox.height;
+      };
+    } else {
+      xconvert = yconvert = function(x) {
+        return x;
+      };
+    }
+    if (((_ref = window.pygal) != null ? _ref.config : void 0) != null) {
+      if (window.pygal.config.no_prefix != null) {
+        config = window.pygal.config;
+      } else {
+        uid = ctx.id.replace('chart-', '');
+        config = window.pygal.config[uid];
+      }
+    } else {
+      config = window.config;
+    }
+    tooltip_el = null;
+    graph = $('.graph').one();
+    tt = $('.tooltip', ctx).one();
+    _ref1 = $('.reactive', ctx);
+    for (_i = 0, _len = _ref1.length; _i < _len; _i++) {
+      el = _ref1[_i];
+      el.addEventListener('mouseenter', (function(el) {
+        return function() {
+          return el.classList.add('active');
+        };
+      })(el));
+      el.addEventListener('mouseleave', (function(el) {
+        return function() {
+          return el.classList.remove('active');
+        };
+      })(el));
+    }
+    _ref2 = $('.activate-serie', ctx);
+    for (_j = 0, _len1 = _ref2.length; _j < _len1; _j++) {
+      el = _ref2[_j];
+      num = el.id.replace('activate-serie-', '');
+      el.addEventListener('mouseenter', (function(num) {
+        return function() {
+          var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
+          _ref3 = $('.serie-' + num + ' .reactive', ctx);
+          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
+            re = _ref3[_k];
+            re.classList.add('active');
+          }
+          _ref4 = $('.serie-' + num + ' .showable', ctx);
+          _results = [];
+          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
+            re = _ref4[_l];
+            _results.push(re.classList.add('shown'));
+          }
+          return _results;
+        };
+      })(num));
+      el.addEventListener('mouseleave', (function(num) {
+        return function() {
+          var re, _k, _l, _len2, _len3, _ref3, _ref4, _results;
+          _ref3 = $('.serie-' + num + ' .reactive', ctx);
+          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
+            re = _ref3[_k];
+            re.classList.remove('active');
+          }
+          _ref4 = $('.serie-' + num + ' .showable', ctx);
+          _results = [];
+          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
+            re = _ref4[_l];
+            _results.push(re.classList.remove('shown'));
+          }
+          return _results;
+        };
+      })(num));
+      el.addEventListener('click', (function(el, num) {
+        return function() {
+          var ov, re, rect, show, _k, _l, _len2, _len3, _ref3, _ref4, _results;
+          rect = $('rect', el).one();
+          show = rect.style.fill !== '';
+          rect.style.fill = show ? '' : 'transparent';
+          _ref3 = $('.serie-' + num + ' .reactive', ctx);
+          for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
+            re = _ref3[_k];
+            re.style.display = show ? '' : 'none';
+          }
+          _ref4 = $('.text-overlay .serie-' + num, ctx);
+          _results = [];
+          for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
+            ov = _ref4[_l];
+            _results.push(ov.style.display = show ? '' : 'none');
+          }
+          return _results;
+        };
+      })(el, num));
+    }
+    _ref3 = $('.tooltip-trigger', ctx);
+    for (_k = 0, _len2 = _ref3.length; _k < _len2; _k++) {
+      el = _ref3[_k];
+      el.addEventListener('mouseenter', (function(el) {
+        return function() {
+          return tooltip_el = tooltip(el);
+        };
+      })(el));
+    }
+    tt.addEventListener('mouseenter', function() {
+      return tooltip_el != null ? tooltip_el.classList.add('active') : void 0;
+    });
+    tt.addEventListener('mouseleave', function() {
+      return tooltip_el != null ? tooltip_el.classList.remove('active') : void 0;
+    });
+    ctx.addEventListener('mouseleave', function() {
+      if (tooltip_timeout) {
+        clearTimeout(tooltip_timeout);
+      }
+      return untooltip(0);
+    });
+    graph.addEventListener('mousemove', function(el) {
+      if (tooltip_timeout) {
+        return;
+      }
+      if (!matches(el.target, '.background')) {
+        return;
+      }
+      return untooltip(1000);
+    });
+    tooltip = function(el) {
+      var a, baseline, cls, current_x, current_y, dy, h, i, key, keys, label, legend, name, plot_x, plot_y, rect, serie_index, subval, text, text_group, texts, traversal, value, w, x, x_elt, x_label, xlink, y, y_elt, _l, _len3, _len4, _len5, _m, _n, _ref4, _ref5, _ref6, _ref7, _ref8;
+      clearTimeout(tooltip_timeout);
+      tooltip_timeout = null;
+      tt.style.opacity = 1;
+      tt.style.display = '';
+      text_group = $('g.text', tt).one();
+      rect = $('rect', tt).one();
+      text_group.innerHTML = '';
+      label = sibl(el, '.label').one().textContent;
+      x_label = sibl(el, '.x_label').one().textContent;
+      value = sibl(el, '.value').one().textContent;
+      xlink = sibl(el, '.xlink').one().textContent;
+      serie_index = null;
+      parent = el;
+      traversal = [];
+      while (parent) {
+        traversal.push(parent);
+        if (parent.classList.contains('series')) {
+          break;
+        }
+        parent = parent.parentElement;
+      }
+      if (parent) {
+        _ref4 = parent.classList;
+        for (_l = 0, _len3 = _ref4.length; _l < _len3; _l++) {
+          cls = _ref4[_l];
+          if (cls.indexOf('serie-') === 0) {
+            serie_index = +cls.replace('serie-', '');
+            break;
+          }
+        }
+      }
+      legend = null;
+      if (serie_index !== null) {
+        legend = config.legends[serie_index];
+      }
+      dy = 0;
+      keys = [[label, 'label']];
+      _ref5 = value.split('\n');
+      for (i = _m = 0, _len4 = _ref5.length; _m < _len4; i = ++_m) {
+        subval = _ref5[i];
+        keys.push([subval, 'value-' + i]);
+      }
+      if (config.tooltip_fancy_mode) {
+        keys.push([xlink, 'xlink']);
+        keys.unshift([x_label, 'x_label']);
+        keys.unshift([legend, 'legend']);
+      }
+      texts = {};
+      for (_n = 0, _len5 = keys.length; _n < _len5; _n++) {
+        _ref6 = keys[_n], key = _ref6[0], name = _ref6[1];
+        if (key) {
+          text = document.createElementNS(svg_ns, 'text');
+          text.textContent = key;
+          text.setAttribute('x', padding);
+          text.setAttribute('dy', dy);
+          text.classList.add(name.indexOf('value') === 0 ? 'value' : name);
+          if (name.indexOf('value') === 0 && config.tooltip_fancy_mode) {
+            text.classList.add('color-' + serie_index);
+          }
+          if (name === 'xlink') {
+            a = document.createElementNS(svg_ns, 'a');
+            a.setAttributeNS(xlink_ns, 'href', key);
+            a.textContent = void 0;
+            a.appendChild(text);
+            text.textContent = 'Link >';
+            text_group.appendChild(a);
+          } else {
+            text_group.appendChild(text);
+          }
+          dy += text.getBBox().height + padding / 2;
+          baseline = padding;
+          if (text.style.dominantBaseline !== void 0) {
+            text.style.dominantBaseline = 'text-before-edge';
+          } else {
+            baseline += text.getBBox().height * .8;
+          }
+          text.setAttribute('y', baseline);
+          texts[name] = text;
+        }
+      }
+      w = text_group.getBBox().width + 2 * padding;
+      h = text_group.getBBox().height + 2 * padding;
+      rect.setAttribute('width', w);
+      rect.setAttribute('height', h);
+      if (texts.value) {
+        texts.value.setAttribute('dx', (w - texts.value.getBBox().width) / 2 - padding);
+      }
+      if (texts.x_label) {
+        texts.x_label.setAttribute('dx', w - texts.x_label.getBBox().width - 2 * padding);
+      }
+      if (texts.xlink) {
+        texts.xlink.setAttribute('dx', w - texts.xlink.getBBox().width - 2 * padding);
+      }
+      x_elt = sibl(el, '.x').one();
+      y_elt = sibl(el, '.y').one();
+      x = parseInt(x_elt.textContent);
+      if (x_elt.classList.contains('centered')) {
+        x -= w / 2;
+      } else if (x_elt.classList.contains('left')) {
+        x -= w;
+      } else if (x_elt.classList.contains('auto')) {
+        x = xconvert(el.getBBox().x + el.getBBox().width / 2) - w / 2;
+      }
+      y = parseInt(y_elt.textContent);
+      if (y_elt.classList.contains('centered')) {
+        y -= h / 2;
+      } else if (y_elt.classList.contains('top')) {
+        y -= h;
+      } else if (y_elt.classList.contains('auto')) {
+        y = yconvert(el.getBBox().y + el.getBBox().height / 2) - h / 2;
+      }
+      _ref7 = get_translation(tt.parentElement), plot_x = _ref7[0], plot_y = _ref7[1];
+      if (x + w + plot_x > config.width) {
+        x = config.width - w - plot_x;
+      }
+      if (y + h + plot_y > config.height) {
+        y = config.height - h - plot_y;
+      }
+      if (x + plot_x < 0) {
+        x = -plot_x;
+      }
+      if (y + plot_y < 0) {
+        y = -plot_y;
+      }
+      _ref8 = get_translation(tt), current_x = _ref8[0], current_y = _ref8[1];
+      if (current_x === x && current_y === y) {
+        return el;
+      }
+      tt.setAttribute('transform', "translate(" + x + " " + y + ")");
+      return el;
+    };
+    return untooltip = function(ms) {
+      return tooltip_timeout = setTimeout(function() {
+        tt.style.display = 'none';
+        tt.style.opacity = 0;
+        if (tooltip_el != null) {
+          tooltip_el.classList.remove('active');
+        }
+        return tooltip_timeout = null;
+      }, ms);
+    };
+  };
+
+  init_svg = function() {
+    var chart, charts, _i, _len, _results;
+    charts = $('.pygal-chart');
+    if (charts.length) {
+      _results = [];
+      for (_i = 0, _len = charts.length; _i < _len; _i++) {
+        chart = charts[_i];
+        _results.push(init(chart));
+      }
+      return _results;
+    }
+  };
+
+  if (document.readyState !== 'loading') {
+    init_svg();
+  } else {
+    document.addEventListener('DOMContentLoaded', function() {
+      return init_svg();
+    });
+  }
+
+  window.pygal = window.pygal || {};
+
+  window.pygal.init = init;
+
+  window.pygal.init_svg = init_svg;
+
+}).call(this);
diff --git a/combo/apps/dataviz/templates/combo/chartngcell.html b/combo/apps/dataviz/templates/combo/chartngcell.html
new file mode 100644
index 00000000..1b614b81
--- /dev/null
+++ b/combo/apps/dataviz/templates/combo/chartngcell.html
@@ -0,0 +1,24 @@
+{% load i18n %}
+{% if cell.title %}<h2>{{cell.title}}</h2>{% endif %}
+{% if cell.chart_type == "table" %}
+{{table|safe}}
+{% else %}
+<div style="min-height: {{cell.height}}px">
+<embed id="chart-{{cell.id}}" type="image/svg+xml"/>
+</div>
+<script>
+$(function() {
+  var last_width = 1;
+  $(window).on('load resize', function() {
+    var chart_cell = $('#chart-{{cell.id}}').parent();
+    var new_width = Math.floor($(chart_cell).width());
+    var ratio = new_width / last_width;
+    if (ratio > 1.2 || ratio < 0.8) {
+      $('#chart-{{cell.id}}').attr('src',
+            "{% url 'combo-dataviz-graph' cell=cell.id %}?width=" + new_width);
+      last_width = new_width;
+    }
+  });
+});
+</script>
+{% endif %}
diff --git a/combo/apps/dataviz/templates/combo/chartngcell_form.html b/combo/apps/dataviz/templates/combo/chartngcell_form.html
new file mode 100644
index 00000000..b72a5663
--- /dev/null
+++ b/combo/apps/dataviz/templates/combo/chartngcell_form.html
@@ -0,0 +1,8 @@
+<div style="position: relative">
+{{ form.as_p }}
+{% if cell.chart_type != "table" %}
+<div style="position: absolute; right: 0; top: 0; width: 300px; height: 150px">
+  <embed type="image/svg+xml" src="{% url 'combo-dataviz-graph' cell=cell.id %}?width=300&height=150"/>
+</div>
+{% endif %}
+</div>
diff --git a/combo/apps/dataviz/urls.py b/combo/apps/dataviz/urls.py
index 4823cdf9..0ce367be 100644
--- a/combo/apps/dataviz/urls.py
+++ b/combo/apps/dataviz/urls.py
@@ -16,9 +16,11 @@
 
 from django.conf.urls import url
 
-from .views import ajax_gauge_count
+from .views import ajax_gauge_count, dataviz_graph
 
 urlpatterns = [
     url(r'^ajax/gauge-count/(?P<cell>[\w_-]+)/$',
         ajax_gauge_count, name='combo-ajax-gauge-count'),
+    url(r'^api/dataviz/graph/(?P<cell>[\w_-]+)/$',
+        dataviz_graph, name='combo-dataviz-graph'),
 ]
diff --git a/combo/apps/dataviz/views.py b/combo/apps/dataviz/views.py
index eaad3061..75c8dae1 100644
--- a/combo/apps/dataviz/views.py
+++ b/combo/apps/dataviz/views.py
@@ -1,5 +1,5 @@
 # combo - content management system
-# Copyright (C) 2015  Entr'ouvert
+# Copyright (C) 2015-2019  Entr'ouvert
 #
 # This program is free software: you can redistribute it and/or modify it
 # under the terms of the GNU Affero General Public License as published
@@ -14,13 +14,27 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from django.core.exceptions import PermissionDenied
 from django.http import HttpResponse
 
 from combo.utils import get_templated_url, requests
-from .models import Gauge
+from .models import Gauge, ChartNgCell
 
 
 def ajax_gauge_count(request, *args, **kwargs):
     gauge = Gauge.objects.get(id=kwargs['cell'])
     response = requests.get(get_templated_url(gauge.data_source))
     return HttpResponse(response.content, content_type='text/json')
+
+
+def dataviz_graph(request, *args, **kwargs):
+    cell = ChartNgCell.objects.get(id=kwargs.get('cell'))
+    if not cell.page.is_visible(request.user):
+        raise PermissionDenied()
+    if not cell.is_visible(request.user):
+        raise PermissionDenied()
+    chart = cell.get_chart(
+            width=int(request.GET.get('width', 0)) or None,
+            height=int(request.GET.get('height', 0)) or int(cell.height)
+    )
+    return HttpResponse(chart.render(), content_type='image/svg+xml')
diff --git a/combo/settings.py b/combo/settings.py
index 5b6025e8..7eb98b7e 100644
--- a/combo/settings.py
+++ b/combo/settings.py
@@ -334,6 +334,7 @@ PWA_NOTIFICATION_ICON_URL = None
 
 # hide work-in-progress/experimental/broken/legacy/whatever cells for now
 BOOKING_CALENDAR_CELL_ENABLED = False
+LEGACY_CHART_CELL_ENABLED = False
 NEWSLETTERS_CELL_ENABLED = False
 USERSEARCH_CELL_ENABLED = False
 
diff --git a/debian/control b/debian/control
index fb165de3..28d310d5 100644
--- a/debian/control
+++ b/debian/control
@@ -24,7 +24,9 @@ Depends: ${misc:Depends}, ${python:Depends},
     python-django-haystack (>= 2.4.0),
     python-sorl-thumbnail,
     python-pil,
-    python-pywebpush
+    python-pywebpush,
+    python-pygal,
+    python-lxml
 Recommends: python-django-mellon, python-whoosh
 Conflicts: python-lingo
 Description: Portal Management System (Python module)
diff --git a/setup.py b/setup.py
index 5e773fab..b26c6087 100644
--- a/setup.py
+++ b/setup.py
@@ -166,6 +166,8 @@ setup(
         'Pillow',
         'pyproj',
         'pywebpush',
+        'pygal',
+        'lxml',
         ],
     zip_safe=False,
     cmdclass={
diff --git a/tests/settings.py b/tests/settings.py
index b7ec4d10..839c02b8 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -61,6 +61,7 @@ if 'DISABLE_MIGRATIONS' in os.environ:
 FAMILY_SERVICE = {'root': '/'}
 
 BOOKING_CALENDAR_CELL_ENABLED = True
+LEGACY_CHART_CELL_ENABLED = True
 NEWSLETTERS_CELL_ENABLED = True
 
 USER_PROFILE_CONFIG = {
diff --git a/tests/test_dataviz.py b/tests/test_dataviz.py
index 2b383226..a985dd37 100644
--- a/tests/test_dataviz.py
+++ b/tests/test_dataviz.py
@@ -1,10 +1,16 @@
+import json
+
 import mock
 import pytest
+from httmock import HTTMock
 
+from django.contrib.auth.models import User, Group
 from django.test import override_settings
 
 from combo.data.models import Page
-from combo.apps.dataviz.models import Gauge
+from combo.apps.dataviz.models import Gauge, ChartNgCell
+
+from .test_public import login, normal_user
 
 pytestmark = pytest.mark.django_db
 
@@ -37,3 +43,211 @@ def test_json_gauge(app, cell):
             resp = app.get('/ajax/gauge-count/%s/' % cell.id)
             assert resp.text == 'xxx'
             assert requests_get.call_args[0][0] == 'http://www.example.net/XXX'
+
+
+VISUALIZATION_JSON = [
+    {
+        'data-url': 'https://bijoe.example.com/visualization/1/json/',
+        'path': 'https://bijoe.example.com/visualization/1/iframe/?signature=123',
+        'name': 'example visualization (X)',
+        'slug': 'example',
+    },
+    {
+        'data-url': 'https://bijoe.example.com/visualization/2/json/',
+        'path': 'https://bijoe.example.com/visualization/2/iframe/?signature=123',
+        'name': 'second visualization (Y)',
+        'slug': 'second',
+    },
+    {
+        'data-url': 'https://bijoe.example.com/visualization/3/json/',
+        'path': 'https://bijoe.example.com/visualization/3/iframe/?signature=123',
+        'name': 'third visualization (X/Y)',
+        'slug': 'third',
+    },
+    {
+        'data-url': 'https://bijoe.example.com/visualization/4/json/',
+        'path': 'https://bijoe.example.com/visualization/4/iframe/?signature=123',
+        'name': 'fourth visualization (no axis)',
+        'slug': 'fourth',
+    },
+]
+
+
+def bijoe_mock(url, request):
+    if url.path == '/visualization/json/':
+        return {'content': json.dumps(VISUALIZATION_JSON), 'request': request, 'status_code': 200}
+    if url.path == '/visualization/1/json/':
+        response = {
+            'format': '1',
+            'data': [222, 134, 53],
+            'axis': {
+                'x_labels': ['web', 'mail', 'email']
+            }
+        }
+        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
+    if url.path == '/visualization/2/json/':
+        response = {
+            'format': '1',
+            'data': [222, 134, 53],
+            'axis': {
+                'y_labels': ['web', 'mail', 'email']
+            }
+        }
+        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
+    if url.path == '/visualization/3/json/':
+        response = {
+            'format': '1',
+            'data': [
+                [222, 134, 53],
+                [122, 114, 33],
+            ],
+            'axis': {
+                'x_labels': ['web', 'mail', 'email'],
+                'y_labels': ['foo', 'bar'],
+            }
+        }
+        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
+    if url.path == '/visualization/4/json/':
+        response = {
+            'format': '1',
+            'data': 222,
+            'axis': {}
+        }
+        return {'content': json.dumps(response), 'request': request, 'status_code': 200}
+
+
+def test_chartng_cell(app):
+    page = Page(title='One', slug='index')
+    page.save()
+
+    with override_settings(KNOWN_SERVICES={
+            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
+                      'secret': 'combo', 'orig': 'combo'}}}):
+        with HTTMock(bijoe_mock):
+            cell = ChartNgCell(page=page, order=1)
+            cell.data_reference = 'plop:example'
+            cell.save()
+            assert cell.cached_json == VISUALIZATION_JSON[0]
+
+            # bar
+            chart = cell.get_chart()
+            assert chart.__class__.__name__ == 'Bar'
+            assert chart.x_labels == ['web', 'mail', 'email']
+            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
+
+            # horizontal bar
+            cell.chart_type = 'horizontal-bar'
+            chart = cell.get_chart()
+            assert chart.__class__.__name__ == 'HorizontalBar'
+            assert chart.x_labels == ['web', 'mail', 'email']
+            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
+
+            # pie
+            cell.chart_type = 'pie'
+            chart = cell.get_chart()
+            assert chart.__class__.__name__ == 'Pie'
+            assert chart.x_labels == ['web', 'mail', 'email']
+            assert chart.raw_series == [
+                ([222], {'title': u'web'}),
+                ([134], {'title': u'mail'}),
+                ([53], {'title': u'email'})
+            ]
+
+            # data in Y
+            cell.chart_type = 'bar'
+            cell.data_reference = 'plop:second'
+            cell.save()
+            assert cell.cached_json == VISUALIZATION_JSON[1]
+
+            chart = cell.get_chart()
+            assert chart.x_labels == ['web', 'mail', 'email']
+            assert chart.raw_series == [([222, 134, 53], {'title': ''})]
+
+            # data in X/Y
+            cell.chart_type = 'bar'
+            cell.data_reference = 'plop:third'
+            cell.save()
+            assert cell.cached_json == VISUALIZATION_JSON[2]
+
+            chart = cell.get_chart()
+            assert chart.x_labels == ['web', 'mail', 'email']
+            assert chart.raw_series == [
+                ([222, 134, 53], {'title': u'foo'}),
+                ([122, 114, 33], {'title': u'bar'}),
+            ]
+
+            # single data point
+            cell.chart_type = 'bar'
+            cell.data_reference = 'plop:fourth'
+            cell.save()
+            assert cell.cached_json == VISUALIZATION_JSON[3]
+
+            chart = cell.get_chart()
+            assert chart.x_labels == ['']
+            assert chart.raw_series == [([222], {'title': ''})]
+
+def test_chartng_cell_view(app, normal_user):
+    page = Page(title='One', slug='index')
+    page.save()
+
+    with override_settings(KNOWN_SERVICES={
+            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
+                      'secret': 'combo', 'orig': 'combo'}}}):
+        with HTTMock(bijoe_mock):
+            cell = ChartNgCell(page=page, order=1, placeholder='content')
+            cell.data_reference = 'plop:example'
+            cell.save()
+            resp = app.get('/')
+            assert 'min-height: 250px' in resp.text
+            assert '/api/dataviz/graph/1/' in resp.text
+
+            resp = app.get('/api/dataviz/graph/1/?width=400')
+            assert resp.content_type == 'image/svg+xml'
+
+            page.public = False
+            page.save()
+            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
+
+            page.public = True
+            page.save()
+            group = Group(name='plop')
+            group.save()
+            cell.public = False
+            cell.groups = [group]
+            cell.save()
+            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
+
+            app = login(app, username='normal-user', password='normal-user')
+            resp = app.get('/api/dataviz/graph/1/?width=400', status=403)
+
+            normal_user.groups = [group]
+            normal_user.save()
+            resp = app.get('/api/dataviz/graph/1/?width=400', status=200)
+
+            # table visualization
+            cell.chart_type = 'table'
+            cell.save()
+            resp = app.get('/')
+            assert '<td>222</td>' in resp.body
+
+
+def test_chartng_cell_manager(app, admin_user):
+    page = Page(title='One', slug='index')
+    page.save()
+
+    app = login(app)
+
+    with override_settings(KNOWN_SERVICES={
+            'bijoe': {'plop': {'title': 'test', 'url': 'https://bijoe.example.com',
+                      'secret': 'combo', 'orig': 'combo'}}}):
+        with HTTMock(bijoe_mock):
+            cell = ChartNgCell(page=page, order=1, placeholder='content')
+            cell.data_reference = 'plop:example'
+            cell.save()
+            resp = app.get('/manage/pages/%s/' % page.id)
+            assert resp.form['cdataviz_chartngcell-%s-data_reference' % cell.id].options == [
+                (u'plop:example', True, u'example visualization (X)'),
+                (u'plop:fourth', False, u'fourth visualization (no axis)'),
+                (u'plop:second', False, u'second visualization (Y)'),
+                (u'plop:third', False, u'third visualization (X/Y)'),
+            ]
-- 
2.23.0.rc1

