web/views/plots.py
changeset 1888 f36d43f00f32
parent 1886 f0e28ddba7c5
child 1891 dd7c1d7715e7
equal deleted inserted replaced
1887:7e19c94ce0d7 1888:f36d43f00f32
     5 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
     5 :contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
     6 """
     6 """
     7 __docformat__ = "restructuredtext en"
     7 __docformat__ = "restructuredtext en"
     8 
     8 
     9 import os
     9 import os
       
    10 import time
       
    11 
       
    12 from simplejson import dumps
    10 
    13 
    11 from logilab.common import flatten
    14 from logilab.common import flatten
    12 from logilab.mtconverter import html_escape
    15 from logilab.mtconverter import html_escape
    13 
    16 
       
    17 from cubicweb.utils import make_uid
    14 from cubicweb.vregistry import objectify_selector
    18 from cubicweb.vregistry import objectify_selector
    15 from cubicweb.web.views import baseviews
    19 from cubicweb.web.views import baseviews
    16 
    20 
    17 @objectify_selector
    21 @objectify_selector
    18 def plot_selector(cls, req, rset, *args, **kwargs):
    22 def at_least_two_columns(cls, req, rset, *args, **kwargs):
       
    23     if not rset:
       
    24         return 0
       
    25     return len(rset.rows[0]) >= 2
       
    26 
       
    27 @objectify_selector
       
    28 def all_columns_are_numbers(cls, req, rset, *args, **kwargs):
    19     """accept result set with at least one line and two columns of result
    29     """accept result set with at least one line and two columns of result
    20     all columns after second must be of numerical types"""
    30     all columns after second must be of numerical types"""
    21     if not rset:
       
    22         return 0
       
    23     if len(rset.rows[0]) < 2:
       
    24         return 0
       
    25     for etype in rset.description[0]:
    31     for etype in rset.description[0]:
    26         if etype not in ('Int', 'Float'):
    32         if etype not in ('Int', 'Float'):
    27             return 0
    33             return 0
    28     return 1
    34     return 1
    29 
    35 
    30 @objectify_selector
    36 @objectify_selector
    31 def piechart_selector(cls, req, rset, *args, **kwargs):
    37 def second_column_is_number(cls, req, rset, *args, **kwargs):
    32     if not rset:
       
    33         return 0
       
    34     if len(rset.rows[0]) < 2:
       
    35         return 0
       
    36     etype = rset.description[0][1]
    38     etype = rset.description[0][1]
    37     if etype not  in ('Int', 'Float'):
    39     if etype not  in ('Int', 'Float'):
    38         return 0
    40         return 0
    39     return 1
    41     return 1
    40 
    42 
    41 try:
    43 @objectify_selector
    42     import matplotlib
    44 def columns_are_date_then_numbers(cls, req, rset, *args, **kwargs):
    43     import sys
    45     etypes = rset.description[0]
    44     if 'matplotlib.backends' not in sys.modules:
    46     if etypes[0] not in ('Date', 'Datetime'):
    45         matplotlib.use('Agg')
    47         return 0
    46     from pylab import figure
    48     for etype in etypes[1:]:
    47 except ImportError:
    49         if etype not in ('Int', 'Float'):
    48     pass
    50             return 0
    49 else:
    51     return 1
    50     class PlotView(baseviews.AnyRsetView):
       
    51         id = 'plot'
       
    52         title = _('generic plot')
       
    53         binary = True
       
    54         content_type = 'image/png'
       
    55         _plot_count = 0
       
    56         __select__ = plot_selector()
       
    57 
    52 
    58         def call(self, width=None, height=None):
       
    59             # compute dimensions
       
    60             if width is None:
       
    61                 if 'width' in self.req.form:
       
    62                     width = int(self.req.form['width'])
       
    63                 else:
       
    64                     width = 500
       
    65 
    53 
    66             if height is None:
    54 def filterout_nulls(abscissa, plot):
    67                 if 'height' in self.req.form:
    55     filtered = []
    68                     height = int(self.req.form['height'])
    56     for x, y in zip(abscissa, plot):
    69                 else:
    57         if x is None or y is None:
    70                     height = 400
    58             continue
    71             dpi = 100.
    59         filtered.append( (x, y) )
       
    60     return sorted(filtered)
    72 
    61 
    73             # compute data
    62 class PlotView(baseviews.AnyRsetView):
    74             abscisses = [row[0] for row in self.rset]
    63     id = 'plot'
    75             courbes = []
    64     title = _('generic plot')
    76             nbcols = len(self.rset.rows[0])
    65     __select__ = at_least_two_columns() & all_columns_are_numbers()
    77             for col in xrange(1, nbcols):
    66     mode = 'null' # null or time, meant for jquery.flot.js
    78                 courbe = [row[col] for row in self.rset]
       
    79                 courbes.append(courbe)
       
    80             if not courbes:
       
    81                 raise Exception('no data')
       
    82             # plot data
       
    83             fig = figure(figsize=(width/dpi, height/dpi), dpi=dpi)
       
    84             ax = fig.add_subplot(111)
       
    85             colors = 'brgybrgy'
       
    86             try:
       
    87                 float(abscisses[0])
       
    88                 xlabels = None
       
    89             except ValueError:
       
    90                 xlabels = abscisses
       
    91                 abscisses = range(len(xlabels))
       
    92             for idx, courbe in enumerate(courbes):
       
    93                 ax.plot(abscisses, courbe, '%sv-' % colors[idx], label=self.rset.description[0][idx+1])
       
    94             ax.autoscale_view()
       
    95             alldata = flatten(courbes)
       
    96             m, M = min(alldata or [0]), max(alldata or [1])
       
    97             if m is None: m = 0
       
    98             if M is None: M = 0
       
    99             margin = float(M-m)/10
       
   100             ax.set_ylim(m-margin, M+margin)
       
   101             ax.grid(True)
       
   102             ax.legend(loc='best')
       
   103             if xlabels is not None:
       
   104                 ax.set_xticks(abscisses)
       
   105                 ax.set_xticklabels(xlabels)
       
   106             try:
       
   107                 fig.autofmt_xdate()
       
   108             except AttributeError:
       
   109                 # XXX too old version of matplotlib. Ignore safely.
       
   110                 pass
       
   111 
    67 
   112             # save plot
    68     def _build_abscissa(self):
   113             filename = self.build_figname()
    69         return [row[0] for row in self.rset]
   114             fig.savefig(filename, dpi=100)
       
   115             img = open(filename, 'rb')
       
   116             self.w(img.read())
       
   117             img.close()
       
   118             os.remove(filename)
       
   119 
    70 
   120         def build_figname(self):
    71     def _build_data(self, abscissa, plot):
   121             self.__class__._plot_count += 1
    72         return filterout_nulls(abscissa, plot)
   122             return '/tmp/burndown_chart_%s_%d.png' % (self.config.appid,
    73 
   123                                                       self.__class__._plot_count)
    74     def call(self, width=500, height=400):
       
    75         # XXX add excanvas.js if IE
       
    76         self.req.add_js( ('jquery.flot.js', 'cubicweb.flot.js') )
       
    77         # prepare data
       
    78         abscissa = self._build_abscissa()
       
    79         plots = []
       
    80         nbcols = len(self.rset.rows[0])
       
    81         for col in xrange(1, nbcols):
       
    82             plots.append([row[col] for row in self.rset])
       
    83         # plot data
       
    84         plotuid = 'plot%s' % make_uid('foo')
       
    85         self.w(u'<div id="%s" style="width: %spx; height: %spx;"></div>' %
       
    86                (plotuid, width, height))
       
    87         rqlst = self.rset.syntax_tree()
       
    88         # XXX try to make it work with unions
       
    89         varnames = [var.name for var in rqlst.children[0].get_selected_variables()][1:]
       
    90         plotdefs = []
       
    91         plotdata = []
       
    92         for idx, (varname, plot) in enumerate(zip(varnames, plots)):
       
    93             plotid = '%s_%s' % (plotuid, idx)
       
    94             data = self._build_data(abscissa, plot)
       
    95             plotdefs.append('var %s = %s;' % (plotid, dumps(data)))
       
    96             plotdata.append("{label: '%s', data: %s}" % (varname, plotid))
       
    97         self.req.html_headers.add_onload('''
       
    98 %(plotdefs)s
       
    99 jQuery.plot(jQuery("#%(plotuid)s"), [%(plotdata)s],
       
   100     {points: {show: true},
       
   101      lines: {show: true},
       
   102      grid: {hoverable: true},
       
   103      xaxis: {mode: %(mode)s}});
       
   104 jQuery('#%(plotuid)s').bind('plothover', onPlotHover);
       
   105 ''' % {'plotdefs': '\n'.join(plotdefs),
       
   106        'plotuid': plotuid,
       
   107        'plotdata': ','.join(plotdata),
       
   108        'mode': self.mode})
       
   109 
       
   110 
       
   111 class TimeSeriePlotView(PlotView):
       
   112     id = 'plot'
       
   113     title = _('generic plot')
       
   114     __select__ = at_least_two_columns() & columns_are_date_then_numbers()
       
   115     mode = '"time"'
       
   116 
       
   117     def _build_abscissa(self):
       
   118         abscissa = [time.mktime(row[0].timetuple()) * 1000
       
   119                     for row in self.rset]
       
   120         return abscissa
       
   121 
       
   122     def _build_data(self, abscissa, plot):
       
   123         data = []
       
   124         # XXX find a way to get rid of the 3rd column and find 'mode' in JS
       
   125         for x, y in filterout_nulls(abscissa, plot):
       
   126             data.append( (x, y, x) )
       
   127         return data
   124 
   128 
   125 try:
   129 try:
   126     from GChartWrapper import Pie, Pie3D
   130     from GChartWrapper import Pie, Pie3D
   127 except ImportError:
   131 except ImportError:
   128     pass
   132     pass
   129 else:
   133 else:
   130     class PieChartView(baseviews.AnyRsetView):
   134     class PieChartView(baseviews.AnyRsetView):
   131         id = 'piechart'
   135         id = 'piechart'
   132         pieclass = Pie
   136         pieclass = Pie
   133         __select__ = piechart_selector()
   137         __select__ = at_least_two_columns() & second_column_is_number()
   134 
   138 
   135         def call(self, title=None, width=None, height=None):
   139         def call(self, title=None, width=None, height=None):
   136             piechart = self.pieclass([(row[1] or 0) for row in self.rset])
   140             piechart = self.pieclass([(row[1] or 0) for row in self.rset])
   137             labels = ['%s: %s' % (row[0].encode(self.req.encoding), row[1])
   141             labels = ['%s: %s' % (row[0].encode(self.req.encoding), row[1])
   138                       for row in self.rset]
   142                       for row in self.rset]