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] |