web/views/plots.py
changeset 1888 f36d43f00f32
parent 1886 f0e28ddba7c5
child 1891 dd7c1d7715e7
--- a/web/views/plots.py	Thu May 21 00:42:05 2009 +0200
+++ b/web/views/plots.py	Thu May 21 00:44:57 2009 +0200
@@ -7,120 +7,124 @@
 __docformat__ = "restructuredtext en"
 
 import os
+import time
+
+from simplejson import dumps
 
 from logilab.common import flatten
 from logilab.mtconverter import html_escape
 
+from cubicweb.utils import make_uid
 from cubicweb.vregistry import objectify_selector
 from cubicweb.web.views import baseviews
 
 @objectify_selector
-def plot_selector(cls, req, rset, *args, **kwargs):
+def at_least_two_columns(cls, req, rset, *args, **kwargs):
+    if not rset:
+        return 0
+    return len(rset.rows[0]) >= 2
+
+@objectify_selector
+def all_columns_are_numbers(cls, req, rset, *args, **kwargs):
     """accept result set with at least one line and two columns of result
     all columns after second must be of numerical types"""
-    if not rset:
-        return 0
-    if len(rset.rows[0]) < 2:
-        return 0
     for etype in rset.description[0]:
         if etype not in ('Int', 'Float'):
             return 0
     return 1
 
 @objectify_selector
-def piechart_selector(cls, req, rset, *args, **kwargs):
-    if not rset:
-        return 0
-    if len(rset.rows[0]) < 2:
-        return 0
+def second_column_is_number(cls, req, rset, *args, **kwargs):
     etype = rset.description[0][1]
     if etype not  in ('Int', 'Float'):
         return 0
     return 1
 
-try:
-    import matplotlib
-    import sys
-    if 'matplotlib.backends' not in sys.modules:
-        matplotlib.use('Agg')
-    from pylab import figure
-except ImportError:
-    pass
-else:
-    class PlotView(baseviews.AnyRsetView):
-        id = 'plot'
-        title = _('generic plot')
-        binary = True
-        content_type = 'image/png'
-        _plot_count = 0
-        __select__ = plot_selector()
+@objectify_selector
+def columns_are_date_then_numbers(cls, req, rset, *args, **kwargs):
+    etypes = rset.description[0]
+    if etypes[0] not in ('Date', 'Datetime'):
+        return 0
+    for etype in etypes[1:]:
+        if etype not in ('Int', 'Float'):
+            return 0
+    return 1
+
 
-        def call(self, width=None, height=None):
-            # compute dimensions
-            if width is None:
-                if 'width' in self.req.form:
-                    width = int(self.req.form['width'])
-                else:
-                    width = 500
+def filterout_nulls(abscissa, plot):
+    filtered = []
+    for x, y in zip(abscissa, plot):
+        if x is None or y is None:
+            continue
+        filtered.append( (x, y) )
+    return sorted(filtered)
 
-            if height is None:
-                if 'height' in self.req.form:
-                    height = int(self.req.form['height'])
-                else:
-                    height = 400
-            dpi = 100.
+class PlotView(baseviews.AnyRsetView):
+    id = 'plot'
+    title = _('generic plot')
+    __select__ = at_least_two_columns() & all_columns_are_numbers()
+    mode = 'null' # null or time, meant for jquery.flot.js
+
+    def _build_abscissa(self):
+        return [row[0] for row in self.rset]
+
+    def _build_data(self, abscissa, plot):
+        return filterout_nulls(abscissa, plot)
 
-            # compute data
-            abscisses = [row[0] for row in self.rset]
-            courbes = []
-            nbcols = len(self.rset.rows[0])
-            for col in xrange(1, nbcols):
-                courbe = [row[col] for row in self.rset]
-                courbes.append(courbe)
-            if not courbes:
-                raise Exception('no data')
-            # plot data
-            fig = figure(figsize=(width/dpi, height/dpi), dpi=dpi)
-            ax = fig.add_subplot(111)
-            colors = 'brgybrgy'
-            try:
-                float(abscisses[0])
-                xlabels = None
-            except ValueError:
-                xlabels = abscisses
-                abscisses = range(len(xlabels))
-            for idx, courbe in enumerate(courbes):
-                ax.plot(abscisses, courbe, '%sv-' % colors[idx], label=self.rset.description[0][idx+1])
-            ax.autoscale_view()
-            alldata = flatten(courbes)
-            m, M = min(alldata or [0]), max(alldata or [1])
-            if m is None: m = 0
-            if M is None: M = 0
-            margin = float(M-m)/10
-            ax.set_ylim(m-margin, M+margin)
-            ax.grid(True)
-            ax.legend(loc='best')
-            if xlabels is not None:
-                ax.set_xticks(abscisses)
-                ax.set_xticklabels(xlabels)
-            try:
-                fig.autofmt_xdate()
-            except AttributeError:
-                # XXX too old version of matplotlib. Ignore safely.
-                pass
+    def call(self, width=500, height=400):
+        # XXX add excanvas.js if IE
+        self.req.add_js( ('jquery.flot.js', 'cubicweb.flot.js') )
+        # prepare data
+        abscissa = self._build_abscissa()
+        plots = []
+        nbcols = len(self.rset.rows[0])
+        for col in xrange(1, nbcols):
+            plots.append([row[col] for row in self.rset])
+        # plot data
+        plotuid = 'plot%s' % make_uid('foo')
+        self.w(u'<div id="%s" style="width: %spx; height: %spx;"></div>' %
+               (plotuid, width, height))
+        rqlst = self.rset.syntax_tree()
+        # XXX try to make it work with unions
+        varnames = [var.name for var in rqlst.children[0].get_selected_variables()][1:]
+        plotdefs = []
+        plotdata = []
+        for idx, (varname, plot) in enumerate(zip(varnames, plots)):
+            plotid = '%s_%s' % (plotuid, idx)
+            data = self._build_data(abscissa, plot)
+            plotdefs.append('var %s = %s;' % (plotid, dumps(data)))
+            plotdata.append("{label: '%s', data: %s}" % (varname, plotid))
+        self.req.html_headers.add_onload('''
+%(plotdefs)s
+jQuery.plot(jQuery("#%(plotuid)s"), [%(plotdata)s],
+    {points: {show: true},
+     lines: {show: true},
+     grid: {hoverable: true},
+     xaxis: {mode: %(mode)s}});
+jQuery('#%(plotuid)s').bind('plothover', onPlotHover);
+''' % {'plotdefs': '\n'.join(plotdefs),
+       'plotuid': plotuid,
+       'plotdata': ','.join(plotdata),
+       'mode': self.mode})
 
-            # save plot
-            filename = self.build_figname()
-            fig.savefig(filename, dpi=100)
-            img = open(filename, 'rb')
-            self.w(img.read())
-            img.close()
-            os.remove(filename)
+
+class TimeSeriePlotView(PlotView):
+    id = 'plot'
+    title = _('generic plot')
+    __select__ = at_least_two_columns() & columns_are_date_then_numbers()
+    mode = '"time"'
 
-        def build_figname(self):
-            self.__class__._plot_count += 1
-            return '/tmp/burndown_chart_%s_%d.png' % (self.config.appid,
-                                                      self.__class__._plot_count)
+    def _build_abscissa(self):
+        abscissa = [time.mktime(row[0].timetuple()) * 1000
+                    for row in self.rset]
+        return abscissa
+
+    def _build_data(self, abscissa, plot):
+        data = []
+        # XXX find a way to get rid of the 3rd column and find 'mode' in JS
+        for x, y in filterout_nulls(abscissa, plot):
+            data.append( (x, y, x) )
+        return data
 
 try:
     from GChartWrapper import Pie, Pie3D
@@ -130,7 +134,7 @@
     class PieChartView(baseviews.AnyRsetView):
         id = 'piechart'
         pieclass = Pie
-        __select__ = piechart_selector()
+        __select__ = at_least_two_columns() & second_column_is_number()
 
         def call(self, title=None, width=None, height=None):
             piechart = self.pieclass([(row[1] or 0) for row in self.rset])