From 44a8ad79b89b6d64ac8267df8d251e1fcdfa4e3c Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Thu, 15 Feb 2024 21:48:01 +0100 Subject: [PATCH] Replace google js charts with svg for time graphs as well 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 | 8 ++ postgresqleu/confreg/reporting.py | 43 ++++----- postgresqleu/confreg/reportingforms.py | 7 +- postgresqleu/util/templatetags/svgcharts.py | 101 ++++++++++++++++++++ template/confreg/timereport.html | 65 +------------ template/util/svglinechart.svg | 32 +++++++ 6 files changed, 166 insertions(+), 90 deletions(-) create mode 100644 template/util/svglinechart.svg diff --git a/media/css/confadmin.css b/media/css/confadmin.css index 07bd4cfd..36a9ccd2 100644 --- a/media/css/confadmin.css +++ b/media/css/confadmin.css @@ -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 diff --git a/postgresqleu/confreg/reporting.py b/postgresqleu/confreg/reporting.py index 3c49767b..ad2552e9 100644 --- a/postgresqleu/confreg/reporting.py +++ b/postgresqleu/confreg/reporting.py @@ -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 diff --git a/postgresqleu/confreg/reportingforms.py b/postgresqleu/confreg/reportingforms.py index 69a847dd..785c1ec9 100644 --- a/postgresqleu/confreg/reportingforms.py +++ b/postgresqleu/confreg/reportingforms.py @@ -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()) diff --git a/postgresqleu/util/templatetags/svgcharts.py b/postgresqleu/util/templatetags/svgcharts.py index ef6bf397..479aec02 100644 --- a/postgresqleu/util/templatetags/svgcharts.py +++ b/postgresqleu/util/templatetags/svgcharts.py @@ -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, + }) diff --git a/template/confreg/timereport.html b/template/confreg/timereport.html index 3bc9a834..77cd30ce 100644 --- a/template/confreg/timereport.html +++ b/template/confreg/timereport.html @@ -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%} - - - {%endif%}