Replace google js charts with svg for time graphs as well
authorMagnus Hagander <magnus@hagander.net>
Thu, 15 Feb 2024 20:48:01 +0000 (21:48 +0100)
committerMagnus Hagander <magnus@hagander.net>
Thu, 15 Feb 2024 20:48:01 +0000 (21:48 +0100)
This removes the final dependency on google js apis.

In passing also make it possible to filter the conferences when picking
some, as that has become increasingly annoying as the number of
available conferences grow.

This also removes the non-linear trendlines support as that has never
really been useful anyway. Linear trendlines remain.

media/css/confadmin.css
postgresqleu/confreg/reporting.py
postgresqleu/confreg/reportingforms.py
postgresqleu/util/templatetags/svgcharts.py
template/confreg/timereport.html
template/util/svglinechart.svg [new file with mode: 0644]

index 07bd4cfd0bc30fe2942ddb5db70fe378e74ea701..36a9ccd2b9c595e18233bcf38226e54e9d1b9985 100644 (file)
@@ -287,6 +287,14 @@ div.chartdiv svg text {
     cursor: default;
 }
 
+/* Time graphs */
+svg circle.point {
+    stroke-width: 0;
+    fill: transparent;
+}
+svg circle.point:hover {
+    stroke-width: 0.5;
+}
 
 /*
  * Sponsor admin
index 3c49767b8cf36a547983fc26a6de1683c7845483..ad2552e98ae6176fc4805136d6d89a21f468e254 100644 (file)
@@ -43,11 +43,10 @@ def timereport(request):
                     'form': form,
                     'title': report.title,
                     'ylabel': report.ylabel,
-                    'headers': report.headers,
-                    'graphdata': report.graphdata,
-                    'maxpred': report.maxpred,
-                    'trendlines': report.does_trendlines and trendlines or '',
-                    'trendlines_supported': report.does_trendlines,
+                    'xlabel': 'Days',
+                    'series': report.series,
+                    'dayvals': report.dayvals,
+                    'trendlines': report.does_trendlines and trendlines,
                     'helplink': 'reports#time',
                     })
             except ReportException as e:
@@ -76,44 +75,34 @@ class MultiConferenceReport(object):
         self.title = title
         self.ylabel = ylabel
         self.conferences = conferences
-        self.headers = None
-        self.maxpred = 0
         self.does_trendlines = True
         self.curs = connection.cursor()
+        self.series = []
 
     def run(self):
         (maxday, minday) = self.maxmin()
         if not maxday:
             raise ReportException("There are no %s at this conference." % self.title.lower())
-        allvals = [list(range(maxday, minday - 1, -1)), ]
-        self.headers = ['Days']
-        maxseen = 0
+        self.dayvals = list(range(maxday, minday - 1 if minday <= 0 else -1, -1))
         for c in self.conferences:
             myvals = [r[0] for r in self.fetch_all_data(c, minday, maxday)]
-            allvals.append(myvals)
-            self.headers.append(Header(c.conferencename))
-            maxseen = max(max(myvals), maxseen)
-
-        if maxday - minday:
-            maxpred = float(maxseen) * maxday // (maxday - minday)
-        else:
-            maxpred = 10
-        self.graphdata = list(zip(*allvals))
-        self.maxpred = maxpred
+            self.series.append({
+                'label': c.conferencename,
+                'values': myvals,
+            })
 
 
 class SingleConferenceReport(object):
     def __init__(self, title, conferences):
         self.title = title
         self.ylabel = 'Number of registrations'
-        self.maxpred = 0
         self.does_trendlines = False
 
         if len(conferences) != 1:
             raise ReportException('For this report type you must pick a single conference')
         self.conference = conferences[0]
-        self.headers = None
         self.curs = connection.cursor()
+        self.series = []
 
     def maxmin(self):
         self.curs.execute("SELECT max(startdate-payconfirmedat::date), min(startdate-payconfirmedat::date),max(startdate) FROM confreg_conferenceregistration r INNER JOIN confreg_conference c ON r.conference_id=c.id WHERE r.conference_id=%(id)s AND r.payconfirmedat IS NOT NULL", {
@@ -125,12 +114,12 @@ class SingleConferenceReport(object):
         (maxday, minday, startdate) = self.maxmin()
         if not maxday:
             raise ReportException("There are no %s at this conference." % self.title.lower())
-        allvals = [list(range(maxday, minday - 1, -1)), ]
-        self.headers = ['Days']
+        self.dayvals = list(range(maxday, minday - 1, -1))
         for header, rows in self.fetch_all_data(minday, maxday, startdate):
-            allvals.append([r[0] for r in rows])
-            self.headers.append(Header(header))
-        self.graphdata = list(zip(*allvals))
+            self.series.append({
+                'label': header,
+                'values': [r[0] for r in rows],
+            })
 
 
 # ##########################################################3
index 69a847dd46e73c42bff721bdc1dfe6c01593f543..785c1ec9bcb50282a95de92f95d8c8482ac0b7fc 100644 (file)
@@ -9,13 +9,12 @@ from postgresqleu.util.forms import GroupedModelMultipleChoiceField
 _trendlines = (
     ('', 'None'),
     ('linear', 'Linear'),
-    ('exponential', 'Exponential'),
-    ('polynomial', 'Polynomial'),
 )
 
 
 class TimeReportForm(forms.Form):
     reporttype = forms.ChoiceField(required=True, choices=enumerate([r[0] for r in reporttypes], 1), label="Report type")
+    filter = forms.CharField(required=False, label="Filter", help_text="Type part of conference name to filter list")
     conferences = GroupedModelMultipleChoiceField('series', required=True, queryset=Conference.objects.all().order_by('-startdate'))
     trendline = forms.ChoiceField(required=False, choices=_trendlines)
 
@@ -26,6 +25,10 @@ class TimeReportForm(forms.Form):
         if not self.user.is_superuser:
             self.fields['conferences'].queryset = Conference.objects.filter(series__administrators=self.user)
 
+        self.fields['filter'].widget.attrs.update({
+            'data-filter-select': 'id_conferences',
+        })
+
 
 class QueuePartitionForm(forms.Form):
     report = forms.CharField(required=True, widget=forms.HiddenInput())
index ef6bf397bc98f37ea585ed6c0c2818418a4739ba..479aec02c13982c33774a52f8fed8fe699850019 100644 (file)
@@ -1,8 +1,10 @@
 from django import template
 from django.utils.safestring import mark_safe
 
+import heapq
 import itertools
 import math
+import textwrap
 
 register = template.Library()
 
@@ -115,3 +117,102 @@ def svgbarchart(svgdata, legend=True, wratio=2):
             str(roundedmax): 0,
         },
     })
+
+
+def _linreg(x, y):
+    # Simple linear regression
+    N = len(x)
+    Sx = Sy = Sxx = Syy = Sxy = 0.0
+    for x, y in zip(x, y):
+        Sx = Sx + x
+        Sy = Sy + y
+        Sxx = Sxx + x * x
+        Syy = Syy + y * y
+        Sxy = Sxy + x * y
+    det = Sxx * N - Sx * Sx
+    return (Sxy * N - Sy * Sx) / det, (Sxx * Sy - Sx * Sxy) / det
+
+
+@register.simple_tag
+def svglinechart(xlabels, series, wratio=3, ylabel='', xlabel='', alwayszeroline=False, trendlines=False):
+    colors = itertools.cycle(defaultcolors)
+
+    t = template.loader.get_template('util/svglinechart.svg')
+
+    width = 250
+    height = width // wratio
+
+    serieslen = len(xlabels)
+
+    maxval = 0
+    for s in series:
+        s['maxval'] = max(s['values'])
+    maxval = max(s['maxval'] for s in series)
+
+    # XXX: Make this 20 configurable!
+    roundingvalue = 20
+    numgridlines = 6
+
+    maxval = math.ceil(maxval / roundingvalue) * roundingvalue
+    gridvals = [(x + 1) * maxval // numgridlines for x in range(numgridlines)]
+    gridlines = [(v, height - int(height * v / maxval)) for v in gridvals]
+
+    xvals = [20 + x * 200 / (serieslen - 1) for x in range(serieslen)]
+    xgridvals = xlabels[::int(math.ceil(serieslen / 7))]
+    xgridlines = xvals[::int(math.ceil(serieslen / 7))]
+    xgridvals.append(xlabels[-1])
+    xgridlines.append(xvals[-1])
+    xgrid = zip(xgridvals, xgridlines)
+    zerolineat = None
+    if alwayszeroline and 0 not in xgridvals:
+        try:
+            ofs = xlabels.index(0)
+            xgridvals.append(xlabels[ofs])
+            xgridlines.append(xvals[ofs])
+            zerolineat = xvals[ofs]
+        except ValueError:
+            pass
+
+    for s in series:
+        s['values'] = list(zip(
+            xvals,
+            [height - int(height * v / maxval) for v in s['values']],
+            xlabels,
+            s['values'],
+        ))
+        s['color'] = next(colors)
+
+    legend = [{
+        'label': textwrap.wrap(s['label'], 11)[:2],
+        'color': s['color'],
+        'ypos': i * 8 + 5,
+    } for i, s in enumerate(heapq.nlargest(8, series, key=lambda s: s['maxval']))]
+
+    # Trendlines will only be plotted against the first series
+    if trendlines:
+        a, b = _linreg([float(x[0]) for x in series[0]['values']], [float(y[1]) for y in series[0]['values']])
+        trendline = ((xvals[0], b), (xvals[-1], a * xvals[-1] + b))
+    else:
+        trendline = None
+
+    # X-wise:
+    # 10 pixels label
+    # 10 pixels scale
+    # ---- axis
+    # 200 pixels graph
+    # --- exis
+    # 30 pixels legend
+    # --> total width: 250
+
+    return t.render({
+        'height': height,
+        'halfheight': height / 2,
+        'series': series,
+        'ylabel': ylabel,
+        'xlabel': xlabel,
+        'gridlines': gridlines,
+        'xgrid': xgrid,
+        'zerolineat': zerolineat,
+        'legend': legend,
+        'trendline': trendline,
+    })
index 3bc9a834e013a09c20e335ca1bf53cf1234d9e5e..77cd30ce3350e62bfa2ed4d0566fe6d2a08b9f58 100644 (file)
@@ -1,61 +1,8 @@
 {%extends "confreg/confadmin_base.html" %}
 {%load assets%}
+{%load svgcharts%}
 {%block title%}Time based Reports{%endblock%}
 {%block extrahead%}
-{%asset "css" "jqueryui1" %}
-{%asset "js" "jqueryui1" %}
-{%if graphdata%}
-    <script type="text/javascript" src="https://www.google.com/jsapi"></script>
-    <script type="text/javascript">
-  google.load("visualization", "1", {packages:["corechart"]});
-  google.setOnLoadCallback(drawChart);
-  function drawChart() {
-    var dataTable = new google.visualization.DataTable();
-{%for h in headers%}
-    dataTable.addColumn('{%if forloop.isfirst%}string{%else%}number{%endif%}', '{{h}}');
- {%if h.hastoday%}
-    dataTable.addColumn({type:'boolean', role:'certainty'});
- {%endif%}
-{%endfor%}
-    dataTable.addRows([
-{%for r in graphdata%}[{{r|join:","}}],{%endfor%}
-  ]);
-
-    var options = {
-      'title': '{{title}}',
-      'hAxis': {
-        'direction': -1,
-        'title': 'Days before conference',
-        'minValue': 0,
-      },
-      'vAxis': {
-        'title': '{{ylabel}}',
-{%if trendlines%}
-        'maxValue': {{maxpred}},
-{%endif%}
-      },
-{%if trendlines%}
-      'trendlines': {
-         0: {
-            'type': '{{trendlines}}',
-            'visibleInLegend': true,
-            'opacity': 0.2,
-         },
-      }
-{%endif%}
-    };
-    var chart = new google.visualization.LineChart(document.getElementById('chart_div'));
-    chart.draw(dataTable, options);
-
-    // Make chart resizable
-    $('#chart_div').resizable({
-      stop: function(event, ui) {
-        chart.draw(dataTable, options);
-      },
-    });
-  }
-</script>
-    {%endif%}
     <style>
 ul.errorlist {
   color:red;
@@ -79,11 +26,7 @@ select#id_conferences {
 {%include "confreg/admin_backend_form_content.html" with savebutton="Generate report"%}
       </form>
     </div>
-    <div class="row">
-      <div id="chart_div" style="width: 100%; height: 500px;"></div>
-    </div>
-    <div class="row">
-      Drag lower right corner to resize graph.
-    </div>
-
+{%if series%}
+{% svglinechart dayvals series ylabel=ylabel xlabel=xlabel alwayszeroline=True trendlines=trendlines %}
+{%endif%}
 {%endblock%}
diff --git a/template/util/svglinechart.svg b/template/util/svglinechart.svg
new file mode 100644 (file)
index 0000000..8ad6240
--- /dev/null
@@ -0,0 +1,32 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
+     viewBox="0 -5 250 {{height|add:20}}">
+  <line x1="20" x2="20" y1="0" y2="{{height}}" stroke="black" stroke-width="0.2" />
+  <line x1="220" x2="220" y1="0" y2="{{height}}" stroke="black" stroke-width="0.2" />
+  <line x1="20" x2="220" y1="{{height}}" y2="{{height}}" stroke="black" stroke-width="0.2" />
+{%if ylabel %}
+ <text x="5" y="{{halfheight}}" text-anchor="middle" transform="translate(-35,{{halfheight}}) rotate(270)" font-size="3pt" font-style="italic">{{ylabel}}</text>
+{%endif%}
+{%for g, y in gridlines %}
+ <line x1="20" x2="220" y1="{{y}}" y2="{{y}}" stroke-width="0.2" stroke="gray" stroke-dasharray="1"/>
+ <text x="18" y="{{y}}" text-anchor="end" dominant-baseline="middle" font-size="2pt">{{g}}</text>
+{%endfor%}
+ {%for s in series %}
+  <polyline points="{%for p in s.values%}{{p|slice:"0:2"|join:" "}} {%endfor%}" stroke-width="2" stroke="{{s.color}}" fill="none" style="vector-effect: non-scaling-stroke"/>
+  {%for x, y, xval, yval in s.values%}<circle cx="{{x}}" cy="{{y}}" r="1" stroke="{{s.color}}" class="point"><title>{{xlabel}}: {{xval}}
+{{s.label}}: {{yval}}</title></circle>{%endfor%}
+ {%endfor%}
+ {%for l in legend %}
+  <line x1="224" x2="228" y1="{{l.ypos}}" y2="{{l.ypos}}" stroke="{{l.color}}" stroke-width="0.5" />
+  <text x="230" y="{{l.ypos}}" dominant-baseline="middle" font-size="2pt">{%if l.label|length == 1 %}{{l.label.0}}{%else%}{%for ll in l.label%}<tspan x="230" dy="{%if forloop.counter0 == 0%}-0.5em{%else%}1em{%endif%}">{{ll}}</tspan>{%endfor%}{%endif%}</text>
+ {%endfor%}
+ {%for val, x in xgrid%}
+  <line x1="{{x}}" x2="{{x}}" y1="{{height}}" y2="{{height|add:2}}" stroke="black" stroke-width="0.2" />
+  <text x="{{x}}" y="{{height|add:3}}" dominant-baseline="hanging" text-anchor="middle" font-size="2pt">{{val}}</text>
+ {%endfor%}
+{%if zerolineat%}
+  <line x1="{{zerolineat}}" x2="{{zerolineat}}" y1="0" y2="{{height}}" stroke="black" stroke-width="0.2" />
+{%endif%}
+{%if trendline %}
+  <line x1="{{trendline.0.0}}" x2="{{trendline.1.0}}" y1="{{trendline.0.1}}" y2="{{trendline.1.1}}" stroke="{{series.0.color}}" stroke-width="0.2" stroke-opacity="0.5" />
+{%endif%}
+</svg>