From: Tom Rees Date: Thu, 10 Jan 2013 17:08:10 +0000 Subject: Feature #162: Added sparkline graphs to the overview of analytics. Could be query optimised. X-Git-Url: http://maxious.lambdacomplex.org/git/?p=ckanext-ga-report.git&a=commitdiff&h=99e4dfd375c7d58ee39d2204788190c7ca136fa6 --- Feature #162: Added sparkline graphs to the overview of analytics. Could be query optimised. --- --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.py[co] *.py~ .gitignore +ckan.log # Packages *.egg --- a/README.rst +++ b/README.rst @@ -31,10 +31,12 @@ 2. Ensure you development.ini (or similar) contains the info about your Google Analytics account and configuration:: googleanalytics.id = UA-1010101-1 - googleanalytics.account = Account name (i.e. data.gov.uk, see top level item at https://www.google.com/analytics) + googleanalytics.account = Account name (e.g. data.gov.uk, see top level item at https://www.google.com/analytics) + googleanalytics.token.filepath = ~/pyenv/token.dat ga-report.period = monthly + ga-report.bounce_url = / - Note that your credentials will be readable by system administrators on your server. Rather than use sensitive account details, it is suggested you give access to the GA account to a new Google account that you create just for this purpose. + The ga-report.bounce_url specifies a particular path to record the bounce rate for. Typically it is / (the home page). 3. Set up this extension's database tables using a paster command. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file):: @@ -43,6 +45,12 @@ 4. Enable the extension in your CKAN config file by adding it to ``ckan.plugins``:: ckan.plugins = ga-report + +Problem shooting +---------------- + +* ``(ProgrammingError) relation "ga_url" does not exist`` + This means that the ``paster initdb`` step has not been run successfully. Refer to the installation instructions for this extension. Authorization @@ -75,13 +83,17 @@ $ paster getauthtoken --config=../ckan/development.ini +Now ensure you reference the correct path to your token.dat in your CKAN config file (e.g. development.ini):: + + googleanalytics.token.filepath = ~/pyenv/token.dat + Tutorial -------- -Download some GA data and store it in CKAN's db. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file) and specifying the name of your auth file (token.dat by default) from the previous step:: +Download some GA data and store it in CKAN's database. (Ensure your CKAN pyenv is still activated, run the command from ``src/ckanext-ga-report``, alter the ``--config`` option to point to your site config file) and specifying the name of your auth file (token.dat by default) from the previous step:: - $ paster loadanalytics token.dat latest --config=../ckan/development.ini + $ paster loadanalytics latest --config=../ckan/development.ini The value after the token file is how much data you want to retrieve, this can be --- a/ckanext/ga_report/command.py +++ b/ckanext/ga_report/command.py @@ -1,5 +1,8 @@ import logging import datetime +import os + +from pylons import config from ckan.lib.cli import CkanCommand # No other CKAN imports allowed until _load_config is run, @@ -53,25 +56,65 @@ self.args[0] if self.args else 'credentials.json') +class FixTimePeriods(CkanCommand): + """ + Fixes the 'All' records for GA_Urls + + It is possible that older urls that haven't recently been visited + do not have All records. This command will traverse through those + records and generate valid All records for them. + """ + summary = __doc__.split('\n')[0] + usage = __doc__ + max_args = 0 + min_args = 0 + + def __init__(self, name): + super(FixTimePeriods, self).__init__(name) + + def command(self): + import ckan.model as model + from ga_model import post_update_url_stats + self._load_config() + model.Session.remove() + model.Session.configure(bind=model.meta.engine) + + log = logging.getLogger('ckanext.ga_report') + + log.info("Updating 'All' records for old URLs") + post_update_url_stats() + log.info("Processing complete") + + class LoadAnalytics(CkanCommand): """Get data from Google Analytics API and save it in the ga_model - Usage: paster loadanalytics + Usage: paster loadanalytics - Where is the name of the auth token file from - the getauthtoken step. - - And where is: + Where is: all - data for all time latest - (default) just the 'latest' data YYYY-MM - just data for the specific month """ summary = __doc__.split('\n')[0] usage = __doc__ - max_args = 2 - min_args = 1 + max_args = 1 + min_args = 0 + + def __init__(self, name): + super(LoadAnalytics, self).__init__(name) + self.parser.add_option('-d', '--delete-first', + action='store_true', + default=False, + dest='delete_first', + help='Delete data for the period first') + self.parser.add_option('-s', '--skip_url_stats', + action='store_true', + default=False, + dest='skip_url_stats', + help='Skip the download of URL data - just do site-wide stats') def command(self): self._load_config() @@ -79,17 +122,25 @@ from download_analytics import DownloadAnalytics from ga_auth import (init_service, get_profile_id) + ga_token_filepath = os.path.expanduser(config.get('googleanalytics.token.filepath', '')) + if not ga_token_filepath: + print 'ERROR: In the CKAN config you need to specify the filepath of the ' \ + 'Google Analytics token file under key: googleanalytics.token.filepath' + return + try: - svc = init_service(self.args[0], None) + svc = init_service(ga_token_filepath, None) except TypeError: print ('Have you correctly run the getauthtoken task and ' - 'specified the correct file here') + 'specified the correct token file in the CKAN config under ' + '"googleanalytics.token.filepath"?') return - downloader = DownloadAnalytics(svc, profile_id=get_profile_id(svc)) + downloader = DownloadAnalytics(svc, profile_id=get_profile_id(svc), + delete_first=self.options.delete_first, + skip_url_stats=self.options.skip_url_stats) - time_period = self.args[1] if self.args and len(self.args) > 1 \ - else 'latest' + time_period = self.args[0] if self.args else 'latest' if time_period == 'all': downloader.all_() elif time_period == 'latest': --- a/ckanext/ga_report/controller.py +++ b/ckanext/ga_report/controller.py @@ -9,10 +9,11 @@ import sqlalchemy from sqlalchemy import func, cast, Integer import ckan.model as model -from ga_model import GA_Url, GA_Stat, GA_ReferralStat +from ga_model import GA_Url, GA_Stat, GA_ReferralStat, GA_Publisher log = logging.getLogger('ckanext.ga-report') +DOWNLOADS_AVAILABLE_FROM = '2012-12' def _get_month_name(strdate): import calendar @@ -21,12 +22,34 @@ return '%s %s' % (calendar.month_name[d.tm_mon], d.tm_year) -def _month_details(cls): +def _month_details(cls, stat_key=None): + ''' + Returns a list of all the periods for which we have data, unfortunately + knows too much about the type of the cls being passed as GA_Url has a + more complex query + + This may need extending if we add a period_name to the stats + ''' months = [] - vals = model.Session.query(cls.period_name).distinct().all() + day = None + + q = model.Session.query(cls.period_name,cls.period_complete_day)\ + .filter(cls.period_name!='All').distinct(cls.period_name) + if stat_key: + q= q.filter(cls.stat_name==stat_key) + + vals = q.order_by("period_name desc").all() + + if vals and vals[0][1]: + day = int(vals[0][1]) + ordinal = 'th' if 11 <= day <= 13 \ + else {1:'st',2:'nd',3:'rd'}.get(day % 10, 'th') + day = "{day}{ordinal}".format(day=day, ordinal=ordinal) + for m in vals: months.append( (m[0], _get_month_name(m[0]))) - return sorted(months, key=operator.itemgetter(0), reverse=True) + + return months, day class GaReport(BaseController): @@ -34,7 +57,7 @@ def csv(self, month): import csv - q = model.Session.query(GA_Stat) + q = model.Session.query(GA_Stat).filter(GA_Stat.stat_name!='Downloads') if month != 'all': q = q.filter(GA_Stat.period_name==month) entries = q.order_by('GA_Stat.period_name, GA_Stat.stat_name, GA_Stat.key').all() @@ -51,11 +74,12 @@ entry.key.encode('utf-8'), entry.value.encode('utf-8')]) + def index(self): # Get the month details by fetching distinct values and determining the # month names from the values. - c.months = _month_details(GA_Stat) + c.months, c.day = _month_details(GA_Stat) # Work out which month to show, based on query params of the first item c.month_desc = 'all months' @@ -70,35 +94,52 @@ entries = q.order_by('ga_stat.key').all() def clean_key(key, val): - if key in ['Average time on site', 'Pages per visit', 'New visits']: + if key in ['Average time on site', 'Pages per visit', 'New visits', 'Bounce rate (home page)']: val = "%.2f" % round(float(val), 2) if key == 'Average time on site': mins, secs = divmod(float(val), 60) hours, mins = divmod(mins, 60) val = '%02d:%02d:%02d (%s seconds) ' % (hours, mins, secs, val) - if key == 'New visits': + if key in ['New visits','Bounce rate (home page)']: val = "%s%%" % val - if key in ['Bounces', 'Total page views', 'Total visits']: + if key in ['Total page views', 'Total visits']: val = int(val) return key, val + + # Query historic values for sparkline rendering + graph_query = model.Session.query(GA_Stat)\ + .filter(GA_Stat.stat_name=='Totals')\ + .order_by(GA_Stat.period_name) + graph_data = {} + for x in graph_query: + graph_data[x.key] = graph_data.get(x.key,[]) + key, val = clean_key(x.key,float(x.value)) + tooltip = '%s: %s' % (_get_month_name(x.period_name), val) + graph_data[x.key].append( (tooltip,x.value) ) + # Trim the latest month, as it looks like a huge dropoff + for key in graph_data: + graph_data[key] = graph_data[key][:-1] c.global_totals = [] if c.month: for e in entries: key, val = clean_key(e.key, e.value) - c.global_totals.append((key, val)) + sparkline = graph_data[e.key] + c.global_totals.append((key, val, sparkline)) else: d = collections.defaultdict(list) for e in entries: d[e.key].append(float(e.value)) for k, v in d.iteritems(): - if k in ['Bounces', 'Total page views', 'Total visits']: + if k in ['Total page views', 'Total visits']: v = sum(v) else: - v = float(sum(v))/len(v) + v = float(sum(v))/float(len(v)) + sparkline = graph_data[k] key, val = clean_key(k,v) - c.global_totals.append((key, val)) + + c.global_totals.append((key, val, sparkline)) c.global_totals = sorted(c.global_totals, key=operator.itemgetter(0)) keys = { @@ -134,29 +175,7 @@ c.social_referrer_totals.append((shorten_name(entry[0]), fill_out_url(entry[0]),'', entry[1])) - - browser_version_re = re.compile("(.*)\((.*)\)") for k, v in keys.iteritems(): - - def clean_field(key): - if k != 'Browser versions': - return key - m = browser_version_re.match(key) - browser = m.groups()[0].strip() - ver = m.groups()[1] - parts = ver.split('.') - if len(parts) > 1: - if parts[1][0] == '0': - ver = parts[0] - else: - ver = "%s.%s" % (parts[0],parts[1]) - if browser in ['Safari','Android Browser']: # Special case complex version nums - ver = parts[0] - if len(ver) > 2: - ver = "%s%sX" % (ver[0], ver[1]) - - return "%s (%s)" % (browser, ver,) - q = model.Session.query(GA_Stat).\ filter(GA_Stat.stat_name==k) if c.month: @@ -166,77 +185,82 @@ d = collections.defaultdict(int) for e in q.all(): - d[clean_field(e.key)] += int(e.value) + d[e.key] += int(e.value) entries = [] for key, val in d.iteritems(): entries.append((key,val,)) entries = sorted(entries, key=operator.itemgetter(1), reverse=True) - def percent(num, total): - p = 100 * float(num)/float(total) - return "%.2f%%" % round(p, 2) - # Get the total for each set of values and then set the value as # a percentage of the total if k == 'Social sources': - total = sum([x for n,x in c.global_totals if n == 'Total visits']) + total = sum([x for n,x,graph in c.global_totals if n == 'Total visits']) else: total = sum([num for _,num in entries]) - setattr(c, v, [(k,percent(v,total)) for k,v in entries ]) + setattr(c, v, [(k,_percent(v,total)) for k,v in entries ]) return render('ga_report/site/index.html') -class GaPublisherReport(BaseController): +class GaDatasetReport(BaseController): """ - Displays the pageview and visit count for specific publishers based on - the datasets associated with the publisher. + Displays the pageview and visit count for datasets + with options to filter by publisher and time period. """ - def csv(self, month): - - c.month = month if not month =='all' else '' + def publisher_csv(self, month): + ''' + Returns a CSV of each publisher with the total number of dataset + views & visits. + ''' + c.month = month if not month == 'all' else '' response.headers['Content-Type'] = "text/csv; charset=utf-8" response.headers['Content-Disposition'] = str('attachment; filename=publishers_%s.csv' % (month,)) writer = csv.writer(response) - writer.writerow(["Publisher", "Views", "Visits", "Period Name"]) - - for publisher,view,visit in _get_publishers(None): + writer.writerow(["Publisher Title", "Publisher Name", "Views", "Visits", "Period Name"]) + + for publisher,view,visit in _get_top_publishers(None): writer.writerow([publisher.title.encode('utf-8'), + publisher.name.encode('utf-8'), view, visit, month]) - - - def publisher_csv(self, id, month): - - c.month = month if not month =='all' else '' - c.publisher = model.Group.get(id) - if not c.publisher: - abort(404, 'A publisher with that name could not be found') + def dataset_csv(self, id='all', month='all'): + ''' + Returns a CSV with the number of views & visits for each dataset. + + :param id: A Publisher ID or None if you want for all + :param month: The time period, or 'all' + ''' + c.month = month if not month == 'all' else '' + if id != 'all': + c.publisher = model.Group.get(id) + if not c.publisher: + abort(404, 'A publisher with that name could not be found') packages = self._get_packages(c.publisher) response.headers['Content-Type'] = "text/csv; charset=utf-8" response.headers['Content-Disposition'] = \ - str('attachment; filename=%s_%s.csv' % (c.publisher.name, month,)) + str('attachment; filename=datasets_%s_%s.csv' % (c.publisher_name, month,)) writer = csv.writer(response) - writer.writerow(["Publisher", "Views", "Visits", "Period Name"]) - - for package,view,visit in packages: + writer.writerow(["Dataset Title", "Dataset Name", "Views", "Visits", "Resource downloads", "Period Name"]) + + for package,view,visit,downloads in packages: writer.writerow([package.title.encode('utf-8'), + package.name.encode('utf-8'), view, visit, + downloads, month]) - - - def index(self): + def publishers(self): + '''A list of publishers and the number of views/visits for each''' # Get the month details by fetching distinct values and determining the # month names from the values. - c.months = _month_details(GA_Url) + c.months, c.day = _month_details(GA_Url) # Work out which month to show, based on query params of the first item c.month = request.params.get('month', '') @@ -244,57 +268,73 @@ if c.month: c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) - c.top_publishers = _get_publishers() - + c.top_publishers = _get_top_publishers() return render('ga_report/publisher/index.html') - - def _get_packages(self, publisher, count=-1): + def _get_packages(self, publisher=None, count=-1): + '''Returns the datasets in order of views''' + have_download_data = True + month = c.month or 'All' + if month != 'All': + have_download_data = month >= DOWNLOADS_AVAILABLE_FROM + + q = model.Session.query(GA_Url,model.Package)\ + .filter(model.Package.name==GA_Url.package_id)\ + .filter(GA_Url.url.like('/dataset/%')) + if publisher: + q = q.filter(GA_Url.department_id==publisher.name) + q = q.filter(GA_Url.period_name==month) + q = q.order_by('ga_url.pageviews::int desc') + top_packages = [] if count == -1: - count = sys.maxint - - top_packages = [] - q = model.Session.query(GA_Url).\ - filter(GA_Url.department_id==publisher.name).\ - filter(GA_Url.url.like('/dataset/%')) - if c.month: - q = q.filter(GA_Url.period_name==c.month) - q = q.order_by('ga_url.pageviews::int desc') - - if c.month: - for entry in q[:count]: - p = model.Package.get(entry.url[len('/dataset/'):]) - top_packages.append((p,entry.pageviews,entry.visitors)) + entries = q.all() else: - ds = {} - for entry in q.all(): - if len(ds) >= count: - break - p = model.Package.get(entry.url[len('/dataset/'):]) - if not p in ds: - ds[p] = {'views':0, 'visits': 0} - ds[p]['views'] = ds[p]['views'] + int(entry.pageviews) - ds[p]['visits'] = ds[p]['visits'] + int(entry.visitors) - - results = [] - for k, v in ds.iteritems(): - results.append((k,v['views'],v['visits'])) - - top_packages = sorted(results, key=operator.itemgetter(1), reverse=True) + entries = q.limit(count) + + for entry,package in entries: + if package: + # Downloads .... + if have_download_data: + dls = model.Session.query(GA_Stat).\ + filter(GA_Stat.stat_name=='Downloads').\ + filter(GA_Stat.key==package.name) + if month != 'All': # Fetch everything unless the month is specific + dls = dls.filter(GA_Stat.period_name==month) + + downloads = sum(int(d.value) for d in dls.all()) + else: + downloads = 'No data' + top_packages.append((package, entry.pageviews, entry.visits, downloads)) + else: + log.warning('Could not find package associated package') + return top_packages - - def read(self, id): + def read(self): + ''' + Lists the most popular datasets across all publishers + ''' + return self.read_publisher(None) + + def read_publisher(self, id): + ''' + Lists the most popular datasets for a publisher (or across all publishers) + ''' count = 20 - c.publisher = model.Group.get(id) - if not c.publisher: - abort(404, 'A publisher with that name could not be found') + c.publishers = _get_publishers() + + id = request.params.get('publisher', id) + if id and id != 'all': + c.publisher = model.Group.get(id) + if not c.publisher: + abort(404, 'A publisher with that name could not be found') + c.publisher_name = c.publisher.name c.top_packages = [] # package, dataset_views in c.top_packages # Get the month details by fetching distinct values and determining the # month names from the values. - c.months = _month_details(GA_Url) + c.months, c.day = _month_details(GA_Url) # Work out which month to show, based on query params of the first item c.month = request.params.get('month', '') @@ -303,46 +343,59 @@ else: c.month_desc = ''.join([m[1] for m in c.months if m[0]==c.month]) + month = c.month or 'All' c.publisher_page_views = 0 q = model.Session.query(GA_Url).\ - filter(GA_Url.url=='/publisher/%s' % c.publisher.name) - if c.month: - entry = q.filter(GA_Url.period_name==c.month).first() - c.publisher_page_views = entry.pageviews if entry else 0 - else: - for e in q.all(): - c.publisher_page_views = c.publisher_page_views + int(e.pageviews) + filter(GA_Url.url=='/publisher/%s' % c.publisher_name) + entry = q.filter(GA_Url.period_name==c.month).first() + c.publisher_page_views = entry.pageviews if entry else 0 c.top_packages = self._get_packages(c.publisher, 20) return render('ga_report/publisher/read.html') -def _get_publishers(limit=20): +def _get_top_publishers(limit=20): + ''' + Returns a list of the top 20 publishers by dataset visits. + (The number to show can be varied with 'limit') + ''' + month = c.month or 'All' connection = model.Session.connection() q = """ - select department_id, sum(pageviews::int) views, sum(visitors::int) visits + select department_id, sum(pageviews::int) views, sum(visits::int) visits from ga_url - where department_id <> ''""" - if c.month: - q = q + """ - and period_name=%s - """ - q = q + """ - group by department_id order by views desc + where department_id <> '' + and package_id <> '' + and url like '/dataset/%%' + and period_name=%s + group by department_id order by views desc """ if limit: q = q + " limit %s;" % (limit) - # Add this back (before and period_name =%s) if you want to ignore publisher - # homepage views - # and not url like '/publisher/%%' - top_publishers = [] - res = connection.execute(q, c.month) - + res = connection.execute(q, month) for row in res: g = model.Group.get(row[0]) if g: top_publishers.append((g, row[1], row[2])) return top_publishers + +def _get_publishers(): + ''' + Returns a list of all publishers. Each item is a tuple: + (name, title) + ''' + publishers = [] + for pub in model.Session.query(model.Group).\ + filter(model.Group.type=='publisher').\ + filter(model.Group.state=='active').\ + order_by(model.Group.name): + publishers.append((pub.name, pub.title)) + return publishers + +def _percent(num, total): + p = 100 * float(num)/float(total) + return "%.2f%%" % round(p, 2) + --- a/ckanext/ga_report/download_analytics.py +++ b/ckanext/ga_report/download_analytics.py @@ -3,7 +3,7 @@ import datetime import collections from pylons import config - +from ga_model import _normalize_url import ga_model #from ga_client import GA @@ -11,15 +11,20 @@ log = logging.getLogger('ckanext.ga-report') FORMAT_MONTH = '%Y-%m' +MIN_VIEWS = 50 +MIN_VISITS = 20 +MIN_DOWNLOADS = 10 class DownloadAnalytics(object): '''Downloads and stores analytics info''' - def __init__(self, service=None, profile_id=None): + def __init__(self, service=None, profile_id=None, delete_first=False, + skip_url_stats=False): self.period = config['ga-report.period'] self.service = service self.profile_id = profile_id - + self.delete_first = delete_first + self.skip_url_stats = skip_url_stats def specific_month(self, date): import calendar @@ -90,24 +95,47 @@ def download_and_store(self, periods): for period_name, period_complete_day, start_date, end_date in periods: - log.info('Downloading Analytics for period "%s" (%s - %s)', + log.info('Period "%s" (%s - %s)', self.get_full_period_name(period_name, period_complete_day), - start_date.strftime('%Y %m %d'), - end_date.strftime('%Y %m %d')) - data = self.download(start_date, end_date, '~/dataset/[a-z0-9-_]+') - log.info('Storing Dataset Analytics for period "%s"', - self.get_full_period_name(period_name, period_complete_day)) - self.store(period_name, period_complete_day, data, ) - - data = self.download(start_date, end_date, '~/publisher/[a-z0-9-_]+') - log.info('Storing Publisher Analytics for period "%s"', - self.get_full_period_name(period_name, period_complete_day)) - self.store(period_name, period_complete_day, data,) - - ga_model.update_publisher_stats(period_name) # about 30 seconds. - self.sitewide_stats( period_name ) - + start_date.strftime('%Y-%m-%d'), + end_date.strftime('%Y-%m-%d')) + + if self.delete_first: + log.info('Deleting existing Analytics for this period "%s"', + period_name) + ga_model.delete(period_name) + + if not self.skip_url_stats: + # Clean out old url data before storing the new + ga_model.pre_update_url_stats(period_name) + + accountName = config.get('googleanalytics.account') + + log.info('Downloading analytics for dataset views') + data = self.download(start_date, end_date, '~/%s/dataset/[a-z0-9-_]+' % accountName) + + log.info('Storing dataset views (%i rows)', len(data.get('url'))) + self.store(period_name, period_complete_day, data, ) + + log.info('Downloading analytics for publisher views') + data = self.download(start_date, end_date, '~/%s/publisher/[a-z0-9-_]+' % accountName) + + log.info('Storing publisher views (%i rows)', len(data.get('url'))) + self.store(period_name, period_complete_day, data,) + + # Make sure the All records are correct. + ga_model.post_update_url_stats() + + log.info('Aggregating datasets by publisher') + ga_model.update_publisher_stats(period_name) # about 30 seconds. + + + log.info('Downloading and storing analytics for site-wide stats') + self.sitewide_stats( period_name, period_complete_day ) + + log.info('Downloading and storing analytics for social networks') self.update_social_info(period_name, start_date, end_date) + def update_social_info(self, period_name, start_date, end_date): start_date = start_date.strftime('%Y-%m-%d') @@ -130,18 +158,18 @@ data = collections.defaultdict(list) rows = results.get('rows',[]) for row in rows: - from ga_model import _normalize_url - data[_normalize_url(row[0])].append( (row[1], int(row[2]),) ) + url = _normalize_url('http:/' + row[0]) + data[url].append( (row[1], int(row[2]),) ) ga_model.update_social(period_name, data) - def download(self, start_date, end_date, path='~/dataset/[a-z0-9-_]+'): + def download(self, start_date, end_date, path=None): '''Get data from GA for a given time period''' start_date = start_date.strftime('%Y-%m-%d') end_date = end_date.strftime('%Y-%m-%d') query = 'ga:pagePath=%s$' % path - metrics = 'ga:uniquePageviews, ga:visitors' - sort = '-ga:uniquePageviews' + metrics = 'ga:pageviews, ga:visits' + sort = '-ga:pageviews' # Supported query params at # https://developers.google.com/analytics/devguides/reporting/core/v3/reference @@ -155,35 +183,36 @@ max_results=10000, end_date=end_date).execute() - if os.getenv('DEBUG'): - import pprint - pprint.pprint(results) - print 'Total results: %s' % results.get('totalResults') - packages = [] + log.info("There are %d results" % results['totalResults']) for entry in results.get('rows'): (loc,pageviews,visits) = entry - packages.append( ('http:/' + loc, pageviews, visits,) ) # Temporary hack + url = _normalize_url('http:/' + loc) # strips off domain e.g. www.data.gov.uk or data.gov.uk + + if not url.startswith('/dataset/') and not url.startswith('/publisher/'): + # filter out strays like: + # /data/user/login?came_from=http://data.gov.uk/dataset/os-code-point-open + # /403.html?page=/about&from=http://data.gov.uk/publisher/planning-inspectorate + continue + packages.append( (url, pageviews, visits,) ) # Temporary hack return dict(url=packages) def store(self, period_name, period_complete_day, data): if 'url' in data: ga_model.update_url_stats(period_name, period_complete_day, data['url']) - def sitewide_stats(self, period_name): + def sitewide_stats(self, period_name, period_complete_day): import calendar year, month = period_name.split('-') _, last_day_of_month = calendar.monthrange(int(year), int(month)) start_date = '%s-01' % period_name end_date = '%s-%s' % (period_name, last_day_of_month) - print 'Sitewide_stats for %s (%s -> %s)' % (period_name, start_date, end_date) - funcs = ['_totals_stats', '_social_stats', '_os_stats', - '_locale_stats', '_browser_stats', '_mobile_stats'] + '_locale_stats', '_browser_stats', '_mobile_stats', '_download_stats'] for f in funcs: - print ' + Fetching %s stats' % f.split('_')[1] - getattr(self, f)(start_date, end_date, period_name) + log.info('Downloading analytics for %s' % f.split('_')[1]) + getattr(self, f)(start_date, end_date, period_name, period_complete_day) def _get_results(result_data, f): data = {} @@ -192,42 +221,65 @@ data[key] = data.get(key,0) + result[1] return data - def _totals_stats(self, start_date, end_date, period_name): + def _totals_stats(self, start_date, end_date, period_name, period_complete_day): """ Fetches distinct totals, total pageviews etc """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', - max_results=10000, - end_date=end_date).execute() - result_data = results.get('rows') - ga_model.update_sitewide_stats(period_name, "Totals", {'Total page views': result_data[0][0]}) - - results = self.service.data().ga().get( - ids='ga:' + self.profile_id, - start_date=start_date, - metrics='ga:pageviewsPerVisit,ga:bounces,ga:avgTimeOnSite,ga:percentNewVisits,ga:visitors', + metrics='ga:pageviews', + sort='-ga:pageviews', + max_results=10000, + end_date=end_date).execute() + result_data = results.get('rows') + ga_model.update_sitewide_stats(period_name, "Totals", {'Total page views': result_data[0][0]}, + period_complete_day) + + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + start_date=start_date, + metrics='ga:pageviewsPerVisit,ga:avgTimeOnSite,ga:percentNewVisits,ga:visits', max_results=10000, end_date=end_date).execute() result_data = results.get('rows') data = { 'Pages per visit': result_data[0][0], - 'Bounces': result_data[0][1], - 'Average time on site': result_data[0][2], - 'New visits': result_data[0][3], - 'Total visits': result_data[0][4], + 'Average time on site': result_data[0][1], + 'New visits': result_data[0][2], + 'Total visits': result_data[0][3], } - ga_model.update_sitewide_stats(period_name, "Totals", data) - - - def _locale_stats(self, start_date, end_date, period_name): + ga_model.update_sitewide_stats(period_name, "Totals", data, period_complete_day) + + # Bounces from / or another configurable page. + path = '/%s%s' % (config.get('googleanalytics.account'), + config.get('ga-report.bounce_url', '/')) + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + filters='ga:pagePath==%s' % (path,), + start_date=start_date, + metrics='ga:visitBounceRate', + dimensions='ga:pagePath', + max_results=10000, + end_date=end_date).execute() + result_data = results.get('rows') + if not result_data or len(result_data) != 1: + log.error('Could not pinpoint the bounces for path: %s. Got results: %r', + path, result_data) + return + results = result_data[0] + bounces = float(results[1]) + # visitBounceRate is already a % + log.info('Google reports visitBounceRate as %s', bounces) + ga_model.update_sitewide_stats(period_name, "Totals", {'Bounce rate (home page)': float(bounces)}, + period_complete_day) + + + def _locale_stats(self, start_date, end_date, period_name, period_complete_day): """ Fetches stats about language and country """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', + metrics='ga:pageviews', + sort='-ga:pageviews', dimensions="ga:language,ga:country", max_results=10000, end_date=end_date).execute() @@ -235,42 +287,98 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Languages", data) + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Languages", data, period_complete_day) data = {} for result in result_data: data[result[1]] = data.get(result[1], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Country", data) - - - def _social_stats(self, start_date, end_date, period_name): + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Country", data, period_complete_day) + + + def _download_stats(self, start_date, end_date, period_name, period_complete_day): + """ Fetches stats about language and country """ + import ckan.model as model + + data = {} + + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + start_date=start_date, + filters='ga:eventAction==download', + metrics='ga:totalEvents', + sort='-ga:totalEvents', + dimensions="ga:eventLabel", + max_results=10000, + end_date=end_date).execute() + result_data = results.get('rows') + if not result_data: + # We may not have data for this time period, so we need to bail + # early. + log.info("There is no download data for this time period") + return + + def process_result_data(result_data, cached=False): + for result in result_data: + url = result[0].strip() + + # Get package id associated with the resource that has this URL. + q = model.Session.query(model.Resource) + if cached: + r = q.filter(model.Resource.cache_url.like("%s%%" % url)).first() + else: + r = q.filter(model.Resource.url.like("%s%%" % url)).first() + + package_name = r.resource_group.package.name if r else "" + if package_name: + data[package_name] = data.get(package_name, 0) + int(result[1]) + else: + log.warning(u"Could not find resource for URL: {url}".format(url=url)) + continue + + process_result_data(results.get('rows')) + + results = self.service.data().ga().get( + ids='ga:' + self.profile_id, + start_date=start_date, + filters='ga:eventAction==download-cache', + metrics='ga:totalEvents', + sort='-ga:totalEvents', + dimensions="ga:eventLabel", + max_results=10000, + end_date=end_date).execute() + process_result_data(results.get('rows'), cached=False) + + self._filter_out_long_tail(data, MIN_DOWNLOADS) + ga_model.update_sitewide_stats(period_name, "Downloads", data, period_complete_day) + + def _social_stats(self, start_date, end_date, period_name, period_complete_day): """ Finds out which social sites people are referred from """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', + metrics='ga:pageviews', + sort='-ga:pageviews', dimensions="ga:socialNetwork,ga:referralPath", max_results=10000, end_date=end_date).execute() result_data = results.get('rows') - twitter_links = [] data = {} for result in result_data: if not result[0] == '(not set)': data[result[0]] = data.get(result[0], 0) + int(result[2]) - if result[0] == 'Twitter': - twitter_links.append(result[1]) - ga_model.update_sitewide_stats(period_name, "Social sources", data) - - - def _os_stats(self, start_date, end_date, period_name): + self._filter_out_long_tail(data, 3) + ga_model.update_sitewide_stats(period_name, "Social sources", data, period_complete_day) + + + def _os_stats(self, start_date, end_date, period_name, period_complete_day): """ Operating system stats """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', + metrics='ga:pageviews', + sort='-ga:pageviews', dimensions="ga:operatingSystem,ga:operatingSystemVersion", max_results=10000, end_date=end_date).execute() @@ -278,46 +386,73 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Operating Systems", data) - - data = {} - for result in result_data: - key = "%s (%s)" % (result[0],result[1]) - data[key] = result[2] - ga_model.update_sitewide_stats(period_name, "Operating Systems versions", data) - - - def _browser_stats(self, start_date, end_date, period_name): + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Operating Systems", data, period_complete_day) + + data = {} + for result in result_data: + if int(result[2]) >= MIN_VIEWS: + key = "%s %s" % (result[0],result[1]) + data[key] = result[2] + ga_model.update_sitewide_stats(period_name, "Operating Systems versions", data, period_complete_day) + + + def _browser_stats(self, start_date, end_date, period_name, period_complete_day): """ Information about browsers and browser versions """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', + metrics='ga:pageviews', + sort='-ga:pageviews', dimensions="ga:browser,ga:browserVersion", max_results=10000, end_date=end_date).execute() result_data = results.get('rows') + # e.g. [u'Firefox', u'19.0', u'20'] + data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Browsers", data) - - data = {} - for result in result_data: - key = "%s (%s)" % (result[0], result[1]) - data[key] = result[2] - ga_model.update_sitewide_stats(period_name, "Browser versions", data) - - - def _mobile_stats(self, start_date, end_date, period_name): + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Browsers", data, period_complete_day) + + data = {} + for result in result_data: + key = "%s %s" % (result[0], self._filter_browser_version(result[0], result[1])) + data[key] = data.get(key, 0) + int(result[2]) + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Browser versions", data, period_complete_day) + + @classmethod + def _filter_browser_version(cls, browser, version_str): + ''' + Simplifies a browser version string if it is detailed. + i.e. groups together Firefox 3.5.1 and 3.5.2 to be just 3. + This is helpful when viewing stats and good to protect privacy. + ''' + ver = version_str + parts = ver.split('.') + if len(parts) > 1: + if parts[1][0] == '0': + ver = parts[0] + else: + ver = "%s" % (parts[0]) + # Special case complex version nums + if browser in ['Safari', 'Android Browser']: + ver = parts[0] + if len(ver) > 2: + num_hidden_digits = len(ver) - 2 + ver = ver[0] + ver[1] + 'X' * num_hidden_digits + return ver + + def _mobile_stats(self, start_date, end_date, period_name, period_complete_day): """ Info about mobile devices """ results = self.service.data().ga().get( ids='ga:' + self.profile_id, start_date=start_date, - metrics='ga:uniquePageviews', - sort='-ga:uniquePageviews', + metrics='ga:pageviews', + sort='-ga:pageviews', dimensions="ga:mobileDeviceBranding, ga:mobileDeviceInfo", max_results=10000, end_date=end_date).execute() @@ -326,10 +461,23 @@ data = {} for result in result_data: data[result[0]] = data.get(result[0], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Mobile brands", data) + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Mobile brands", data, period_complete_day) data = {} for result in result_data: data[result[1]] = data.get(result[1], 0) + int(result[2]) - ga_model.update_sitewide_stats(period_name, "Mobile devices", data) - + self._filter_out_long_tail(data, MIN_VIEWS) + ga_model.update_sitewide_stats(period_name, "Mobile devices", data, period_complete_day) + + @classmethod + def _filter_out_long_tail(cls, data, threshold=10): + ''' + Given data which is a frequency distribution, filter out + results which are below a threshold count. This is good to protect + privacy. + ''' + for key, value in data.items(): + if value < threshold: + del data[key] + --- a/ckanext/ga_report/ga_auth.py +++ b/ckanext/ga_report/ga_auth.py @@ -53,7 +53,11 @@ return None accountName = config.get('googleanalytics.account') + if not accountName: + raise Exception('googleanalytics.account needs to be configured') webPropertyId = config.get('googleanalytics.id') + if not webPropertyId: + raise Exception('googleanalytics.id needs to be configured') for acc in accounts.get('items'): if acc.get('name') == accountName: accountId = acc.get('id') --- a/ckanext/ga_report/ga_model.py +++ b/ckanext/ga_report/ga_model.py @@ -1,21 +1,21 @@ import re import uuid -from sqlalchemy import Table, Column, MetaData +from sqlalchemy import Table, Column, MetaData, ForeignKey from sqlalchemy import types from sqlalchemy.sql import select -from sqlalchemy.orm import mapper +from sqlalchemy.orm import mapper, relation from sqlalchemy import func import ckan.model as model from ckan.lib.base import * +log = __import__('logging').getLogger(__name__) + def make_uuid(): return unicode(uuid.uuid4()) metadata = MetaData() - - class GA_Url(object): @@ -29,9 +29,10 @@ Column('period_name', types.UnicodeText), Column('period_complete_day', types.Integer), Column('pageviews', types.UnicodeText), - Column('visitors', types.UnicodeText), + Column('visits', types.UnicodeText), Column('url', types.UnicodeText), Column('department_id', types.UnicodeText), + Column('package_id', types.UnicodeText), ) mapper(GA_Url, url_table) @@ -46,6 +47,7 @@ Column('id', types.UnicodeText, primary_key=True, default=make_uuid), Column('period_name', types.UnicodeText), + Column('period_complete_day', types.UnicodeText), Column('stat_name', types.UnicodeText), Column('key', types.UnicodeText), Column('value', types.UnicodeText), ) @@ -64,7 +66,7 @@ Column('period_name', types.UnicodeText), Column('publisher_name', types.UnicodeText), Column('views', types.UnicodeText), - Column('visitors', types.UnicodeText), + Column('visits', types.UnicodeText), Column('toplevel', types.Boolean, default=False), Column('subpublishercount', types.Integer, default=0), Column('parent', types.UnicodeText), @@ -112,12 +114,10 @@ >>> normalize_url('http://data.gov.uk/dataset/weekly_fuel_prices') '/dataset/weekly_fuel_prices' ''' - # Deliberately leaving a / - url = url.replace('http:/','') - return '/' + '/'.join(url.split('/')[2:]) - - -def _get_department_id_of_url(url): + return '/' + '/'.join(url.split('/')[3:]) + + +def _get_package_and_publisher(url): # e.g. /dataset/fuel_prices # e.g. /dataset/fuel_prices/resource/e63380d4 dataset_match = re.match('/dataset/([^/]+)(/.*)?', url) @@ -127,14 +127,15 @@ if dataset: publisher_groups = dataset.get_groups('publisher') if publisher_groups: - return publisher_groups[0].name + return dataset_ref,publisher_groups[0].name + return dataset_ref, None else: publisher_match = re.match('/publisher/([^/]+)(/.*)?', url) if publisher_match: - return publisher_match.groups()[0] - - -def update_sitewide_stats(period_name, stat_name, data): + return None, publisher_match.groups()[0] + return None, None + +def update_sitewide_stats(period_name, stat_name, data, period_complete_day): for k,v in data.iteritems(): item = model.Session.query(GA_Stat).\ filter(GA_Stat.period_name==period_name).\ @@ -144,11 +145,13 @@ item.period_name = period_name item.key = k item.value = v + item.period_complete_day = period_complete_day model.Session.add(item) else: # create the row values = {'id': make_uuid(), 'period_name': period_name, + 'period_complete_day': period_complete_day, 'key': k, 'value': v, 'stat_name': stat_name @@ -157,34 +160,118 @@ model.Session.commit() +def pre_update_url_stats(period_name): + log.debug("Deleting '%s' records" % period_name) + model.Session.query(GA_Url).\ + filter(GA_Url.period_name==period_name).delete() + + count = model.Session.query(GA_Url).\ + filter(GA_Url.period_name == 'All').count() + log.debug("Deleting %d 'All' records" % count) + count = model.Session.query(GA_Url).\ + filter(GA_Url.period_name == 'All').delete() + log.debug("Deleted %d 'All' records" % count) + + model.Session.flush() + model.Session.commit() + model.repo.commit_and_remove() + +def post_update_url_stats(): + + """ Check the distinct url field in ga_url and make sure + it has an All record. If not then create one. + + After running this then every URL should have an All + record regardless of whether the URL has an entry for + the month being currently processed. + """ + query = """select url, pageviews::int, visits::int + from ga_url + where url not in (select url from ga_url where period_name ='All')""" + connection = model.Session.connection() + res = connection.execute(query) + + views, visits = {}, {} + # url, views, visits + for row in res: + views[row[0]] = views.get(row[0], 0) + row[1] + visits[row[0]] = visits.get(row[0], 0) + row[2] + + for key in views.keys(): + package, publisher = _get_package_and_publisher(key) + + values = {'id': make_uuid(), + 'period_name': "All", + 'period_complete_day': 0, + 'url': key, + 'pageviews': views[key], + 'visits': visits[key], + 'department_id': publisher, + 'package_id': publisher + } + model.Session.add(GA_Url(**values)) + model.Session.commit() + def update_url_stats(period_name, period_complete_day, url_data): - for url, views, visitors in url_data: - url = _normalize_url(url) - department_id = _get_department_id_of_url(url) - - # see if the row for this url & month is in the table already + ''' + Given a list of urls and number of hits for each during a given period, + stores them in GA_Url under the period and recalculates the totals for + the 'All' period. + ''' + for url, views, visits in url_data: + package, publisher = _get_package_and_publisher(url) + + item = model.Session.query(GA_Url).\ filter(GA_Url.period_name==period_name).\ filter(GA_Url.url==url).first() if item: - item.period_name = period_name - item.pageviews = views - item.visitors = visitors - item.department_id = department_id + item.pageviews = item.pageviews + views + item.visits = item.visits + visits + if not item.package_id: + item.package_id = package + if not item.department_id: + item.department_id = publisher model.Session.add(item) else: - # create the row values = {'id': make_uuid(), 'period_name': period_name, 'period_complete_day': period_complete_day, 'url': url, 'pageviews': views, - 'visitors': visitors, - 'department_id': department_id + 'visits': visits, + 'department_id': publisher, + 'package_id': package } model.Session.add(GA_Url(**values)) model.Session.commit() + + if package: + old_pageviews, old_visits = 0, 0 + old = model.Session.query(GA_Url).\ + filter(GA_Url.period_name=='All').\ + filter(GA_Url.url==url).all() + old_pageviews = sum([int(o.pageviews) for o in old]) + old_visits = sum([int(o.visits) for o in old]) + + entries = model.Session.query(GA_Url).\ + filter(GA_Url.period_name!='All').\ + filter(GA_Url.url==url).all() + values = {'id': make_uuid(), + 'period_name': 'All', + 'period_complete_day': 0, + 'url': url, + 'pageviews': sum([int(e.pageviews) for e in entries]) + int(old_pageviews), + 'visits': sum([int(e.visits or 0) for e in entries]) + int(old_visits), + 'department_id': publisher, + 'package_id': package + } + + model.Session.add(GA_Url(**values)) + model.Session.commit() + + def update_social(period_name, data): @@ -226,7 +313,7 @@ filter(model.Group.type=='publisher').\ filter(model.Group.state=='active').all() for publisher in publishers: - views, visitors, subpub = update_publisher(period_name, publisher, publisher.name) + views, visits, subpub = update_publisher(period_name, publisher, publisher.name) parent, parents = '', publisher.get_groups('publisher') if parents: parent = parents[0].name @@ -235,7 +322,7 @@ filter(GA_Publisher.publisher_name==publisher.name).first() if item: item.views = views - item.visitors = visitors + item.visits = visits item.publisher_name = publisher.name item.toplevel = publisher in toplevel item.subpublishercount = subpub @@ -247,7 +334,7 @@ 'period_name': period_name, 'publisher_name': publisher.name, 'views': views, - 'visitors': visitors, + 'visits': visits, 'toplevel': publisher in toplevel, 'subpublishercount': subpub, 'parent': parent @@ -257,7 +344,7 @@ def update_publisher(period_name, pub, part=''): - views,visitors,subpub = 0, 0, 0 + views,visits,subpub = 0, 0, 0 for publisher in go_down_tree(pub): subpub = subpub + 1 items = model.Session.query(GA_Url).\ @@ -265,9 +352,9 @@ filter(GA_Url.department_id==publisher.name).all() for item in items: views = views + int(item.pageviews) - visitors = visitors + int(item.visitors) - - return views, visitors, (subpub-1) + visits = visits + int(item.visits) + + return views, visits, (subpub-1) def get_top_level(): @@ -295,3 +382,46 @@ for grandchild in go_down_tree(child): yield grandchild +def delete(period_name): + ''' + Deletes table data for the specified period, or specify 'all' + for all periods. + ''' + for object_type in (GA_Url, GA_Stat, GA_Publisher, GA_ReferralStat): + q = model.Session.query(object_type) + if period_name != 'All': + q = q.filter_by(period_name=period_name) + q.delete() + model.repo.commit_and_remove() + +def get_score_for_dataset(dataset_name): + ''' + Returns a "current popularity" score for a dataset, + based on how many views it has had recently. + ''' + import datetime + now = datetime.datetime.now() + last_month = now - datetime.timedelta(days=30) + period_names = ['%s-%02d' % (last_month.year, last_month.month), + '%s-%02d' % (now.year, now.month), + ] + + score = 0 + for period_name in period_names: + score /= 2 # previous periods are discounted by 50% + entry = model.Session.query(GA_Url)\ + .filter(GA_Url.period_name==period_name)\ + .filter(GA_Url.package_id==dataset_name).first() + # score + if entry: + views = float(entry.pageviews) + if entry.period_complete_day: + views_per_day = views / entry.period_complete_day + else: + views_per_day = views / 15 # guess + score += views_per_day + + score = int(score * 100) + log.debug('Popularity %s: %s', score, dataset_name) + return score + --- a/ckanext/ga_report/helpers.py +++ b/ckanext/ga_report/helpers.py @@ -1,7 +1,9 @@ import logging import operator + import ckan.lib.base as base import ckan.model as model +from ckan.logic import get_action from ckanext.ga_report.ga_model import GA_Url, GA_Publisher from ckanext.ga_report.controller import _get_publishers @@ -27,6 +29,56 @@ } return base.render_snippet('ga_report/ga_popular_datasets.html', **ctx) +def single_popular_dataset(top=20): + '''Returns a random dataset from the most popular ones. + + :param top: the number of top datasets to select from + ''' + import random + + top_datasets = model.Session.query(GA_Url).\ + filter(GA_Url.url.like('/dataset/%')).\ + order_by('ga_url.pageviews::int desc') + num_top_datasets = top_datasets.count() + + dataset = None + if num_top_datasets: + count = 0 + while not dataset: + rand = random.randrange(0, min(top, num_top_datasets)) + ga_url = top_datasets[rand] + dataset = model.Package.get(ga_url.url[len('/dataset/'):]) + if dataset and not dataset.state == 'active': + dataset = None + # When testing, it is possible that top datasets are not available + # so only go round this loop a few times before falling back on + # a random dataset. + count += 1 + if count > 10: + break + if not dataset: + # fallback + dataset = model.Session.query(model.Package)\ + .filter_by(state='active').first() + if not dataset: + return None + dataset_dict = get_action('package_show')({'model': model, + 'session': model.Session, + 'validate': False}, + {'id':dataset.id}) + return dataset_dict + +def single_popular_dataset_html(top=20): + dataset_dict = single_popular_dataset(top) + groups = package.get('groups', []) + publishers = [ g for g in groups if g.get('type') == 'publisher' ] + publisher = publishers[0] if publishers else {'name':'', 'title': ''} + context = { + 'dataset': dataset_dict, + 'publisher': publisher_dict + } + return base.render_snippet('ga_report/ga_popular_single.html', **context) + def most_popular_datasets(publisher, count=20): @@ -37,7 +89,7 @@ results = _datasets_for_publisher(publisher, count) ctx = { - 'dataset_count': len(datasets), + 'dataset_count': len(results), 'datasets': results, 'publisher': publisher @@ -57,7 +109,7 @@ if not p in datasets: datasets[p] = {'views':0, 'visits': 0} datasets[p]['views'] = datasets[p]['views'] + int(entry.pageviews) - datasets[p]['visits'] = datasets[p]['visits'] + int(entry.visitors) + datasets[p]['visits'] = datasets[p]['visits'] + int(entry.visits) results = [] for k, v in datasets.iteritems(): --- a/ckanext/ga_report/plugin.py +++ b/ckanext/ga_report/plugin.py @@ -4,7 +4,8 @@ from ckan.plugins import implements, toolkit from ckanext.ga_report.helpers import (most_popular_datasets, - popular_datasets) + popular_datasets, + single_popular_dataset) log = logging.getLogger('ckanext.ga-report') @@ -26,29 +27,11 @@ 'ga_report_installed': lambda: True, 'popular_datasets': popular_datasets, 'most_popular_datasets': most_popular_datasets, + 'single_popular_dataset': single_popular_dataset } def after_map(self, map): - map.connect( - '/data/site-usage/publisher', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='index' - ) - map.connect( - '/data/site-usage/publisher_{month}.csv', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='csv' - ) - map.connect( - '/data/site-usage/publisher/{id}_{month}.csv', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='publisher_csv' - ) - map.connect( - '/data/site-usage/publisher/{id}', - controller='ckanext.ga_report.controller:GaPublisherReport', - action='read' - ) + # GaReport map.connect( '/data/site-usage', controller='ckanext.ga_report.controller:GaReport', @@ -59,6 +42,43 @@ controller='ckanext.ga_report.controller:GaReport', action='csv' ) + map.connect( + '/data/site-usage/downloads', + controller='ckanext.ga_report.controller:GaReport', + action='downloads' + ) + map.connect( + '/data/site-usage/downloads_{month}.csv', + controller='ckanext.ga_report.controller:GaReport', + action='csv_downloads' + ) + + # GaDatasetReport + map.connect( + '/data/site-usage/publisher', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='publishers' + ) + map.connect( + '/data/site-usage/publishers_{month}.csv', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='publisher_csv' + ) + map.connect( + '/data/site-usage/dataset/datasets_{id}_{month}.csv', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='dataset_csv' + ) + map.connect( + '/data/site-usage/dataset', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='read' + ) + map.connect( + '/data/site-usage/dataset/{id}', + controller='ckanext.ga_report.controller:GaDatasetReport', + action='read_publisher' + ) return map --- /dev/null +++ b/ckanext/ga_report/public/scripts/vendor/jquery.sparkline.modified.js @@ -1,1 +1,3044 @@ - +/* + * This file has been modified! + * I've added a static Tooltip option. + * - Tom Rees + * - January 2013 + */ +/** +* +* jquery.sparkline.js +* +* v2.1 +* (c) Splunk, Inc +* Contact: Gareth Watts (gareth@splunk.com) +* http://omnipotent.net/jquery.sparkline/ +* +* Generates inline sparkline charts from data supplied either to the method +* or inline in HTML +* +* Compatible with Internet Explorer 6.0+ and modern browsers equipped with the canvas tag +* (Firefox 2.0+, Safari, Opera, etc) +* +* License: New BSD License +* +* Copyright (c) 2012, Splunk Inc. +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without modification, +* are permitted provided that the following conditions are met: +* +* * Redistributions of source code must retain the above copyright notice, +* this list of conditions and the following disclaimer. +* * Redistributions in binary form must reproduce the above copyright notice, +* this list of conditions and the following disclaimer in the documentation +* and/or other materials provided with the distribution. +* * Neither the name of Splunk Inc nor the names of its contributors may +* be used to endorse or promote products derived from this software without +* specific prior written permission. +* +* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT +* SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT +* OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +* +* +* Usage: +* $(selector).sparkline(values, options) +* +* If values is undefined or set to 'html' then the data values are read from the specified tag: +*

Sparkline: 1,4,6,6,8,5,3,5

+* $('.sparkline').sparkline(); +* There must be no spaces in the enclosed data set +* +* Otherwise values must be an array of numbers or null values +*

Sparkline: This text replaced if the browser is compatible

+* $('#sparkline1').sparkline([1,4,6,6,8,5,3,5]) +* $('#sparkline2').sparkline([1,4,6,null,null,5,3,5]) +* +* Values can also be specified in an HTML comment, or as a values attribute: +*

Sparkline:

+*

Sparkline:

+* $('.sparkline').sparkline(); +* +* For line charts, x values can also be specified: +*

Sparkline: 1:1,2.7:4,3.4:6,5:6,6:8,8.7:5,9:3,10:5

+* $('#sparkline1').sparkline([ [1,1], [2.7,4], [3.4,6], [5,6], [6,8], [8.7,5], [9,3], [10,5] ]) +* +* By default, options should be passed in as teh second argument to the sparkline function: +* $('.sparkline').sparkline([1,2,3,4], {type: 'bar'}) +* +* Options can also be set by passing them on the tag itself. This feature is disabled by default though +* as there's a slight performance overhead: +* $('.sparkline').sparkline([1,2,3,4], {enableTagOptions: true}) +*

Sparkline: loading

+* Prefix all options supplied as tag attribute with "spark" (configurable by setting tagOptionPrefix) +* +* Supported options: +* lineColor - Color of the line used for the chart +* fillColor - Color used to fill in the chart - Set to '' or false for a transparent chart +* width - Width of the chart - Defaults to 3 times the number of values in pixels +* height - Height of the chart - Defaults to the height of the containing element +* chartRangeMin - Specify the minimum value to use for the Y range of the chart - Defaults to the minimum value supplied +* chartRangeMax - Specify the maximum value to use for the Y range of the chart - Defaults to the maximum value supplied +* chartRangeClip - Clip out of range values to the max/min specified by chartRangeMin and chartRangeMax +* chartRangeMinX - Specify the minimum value to use for the X range of the chart - Defaults to the minimum value supplied +* chartRangeMaxX - Specify the maximum value to use for the X range of the chart - Defaults to the maximum value supplied +* composite - If true then don't erase any existing chart attached to the tag, but draw +* another chart over the top - Note that width and height are ignored if an +* existing chart is detected. +* tagValuesAttribute - Name of tag attribute to check for data values - Defaults to 'values' +* enableTagOptions - Whether to check tags for sparkline options +* tagOptionPrefix - Prefix used for options supplied as tag attributes - Defaults to 'spark' +* disableHiddenCheck - If set to true, then the plugin will assume that charts will never be drawn into a +* hidden dom element, avoding a browser reflow +* disableInteraction - If set to true then all mouseover/click interaction behaviour will be disabled, +* making the plugin perform much like it did in 1.x +* disableTooltips - If set to true then tooltips will be disabled - Defaults to false (tooltips enabled) +* disableHighlight - If set to true then highlighting of selected chart elements on mouseover will be disabled +* defaults to false (highlights enabled) +* highlightLighten - Factor to lighten/darken highlighted chart values by - Defaults to 1.4 for a 40% increase +* tooltipContainer - Specify which DOM element the tooltip should be rendered into - defaults to document.body +* tooltipClassname - Optional CSS classname to apply to tooltips - If not specified then a default style will be applied +* tooltipOffsetX - How many pixels away from the mouse pointer to render the tooltip on the X axis +* tooltipOffsetY - How many pixels away from the mouse pointer to render the tooltip on the r axis +* tooltipFormatter - Optional callback that allows you to override the HTML displayed in the tooltip +* callback is given arguments of (sparkline, options, fields) +* tooltipChartTitle - If specified then the tooltip uses the string specified by this setting as a title +* tooltipFormat - A format string or SPFormat object (or an array thereof for multiple entries) +* to control the format of the tooltip +* tooltipPrefix - A string to prepend to each field displayed in a tooltip +* tooltipSuffix - A string to append to each field displayed in a tooltip +* tooltipSkipNull - If true then null values will not have a tooltip displayed (defaults to true) +* tooltipValueLookups - An object or range map to map field values to tooltip strings +* (eg. to map -1 to "Lost", 0 to "Draw", and 1 to "Win") +* numberFormatter - Optional callback for formatting numbers in tooltips +* numberDigitGroupSep - Character to use for group separator in numbers "1,234" - Defaults to "," +* numberDecimalMark - Character to use for the decimal point when formatting numbers - Defaults to "." +* numberDigitGroupCount - Number of digits between group separator - Defaults to 3 +* +* There are 7 types of sparkline, selected by supplying a "type" option of 'line' (default), +* 'bar', 'tristate', 'bullet', 'discrete', 'pie' or 'box' +* line - Line chart. Options: +* spotColor - Set to '' to not end each line in a circular spot +* minSpotColor - If set, color of spot at minimum value +* maxSpotColor - If set, color of spot at maximum value +* spotRadius - Radius in pixels +* lineWidth - Width of line in pixels +* normalRangeMin +* normalRangeMax - If set draws a filled horizontal bar between these two values marking the "normal" +* or expected range of values +* normalRangeColor - Color to use for the above bar +* drawNormalOnTop - Draw the normal range above the chart fill color if true +* defaultPixelsPerValue - Defaults to 3 pixels of width for each value in the chart +* highlightSpotColor - The color to use for drawing a highlight spot on mouseover - Set to null to disable +* highlightLineColor - The color to use for drawing a highlight line on mouseover - Set to null to disable +* valueSpots - Specify which points to draw spots on, and in which color. Accepts a range map +* +* bar - Bar chart. Options: +* barColor - Color of bars for postive values +* negBarColor - Color of bars for negative values +* zeroColor - Color of bars with zero values +* nullColor - Color of bars with null values - Defaults to omitting the bar entirely +* barWidth - Width of bars in pixels +* colorMap - Optional mappnig of values to colors to override the *BarColor values above +* can be an Array of values to control the color of individual bars or a range map +* to specify colors for individual ranges of values +* barSpacing - Gap between bars in pixels +* zeroAxis - Centers the y-axis around zero if true +* +* tristate - Charts values of win (>0), lose (<0) or draw (=0) +* posBarColor - Color of win values +* negBarColor - Color of lose values +* zeroBarColor - Color of draw values +* barWidth - Width of bars in pixels +* barSpacing - Gap between bars in pixels +* colorMap - Optional mappnig of values to colors to override the *BarColor values above +* can be an Array of values to control the color of individual bars or a range map +* to specify colors for individual ranges of values +* +* discrete - Options: +* lineHeight - Height of each line in pixels - Defaults to 30% of the graph height +* thesholdValue - Values less than this value will be drawn using thresholdColor instead of lineColor +* thresholdColor +* +* bullet - Values for bullet graphs msut be in the order: target, performance, range1, range2, range3, ... +* options: +* targetColor - The color of the vertical target marker +* targetWidth - The width of the target marker in pixels +* performanceColor - The color of the performance measure horizontal bar +* rangeColors - Colors to use for each qualitative range background color +* +* pie - Pie chart. Options: +* sliceColors - An array of colors to use for pie slices +* offset - Angle in degrees to offset the first slice - Try -90 or +90 +* borderWidth - Width of border to draw around the pie chart, in pixels - Defaults to 0 (no border) +* borderColor - Color to use for the pie chart border - Defaults to #000 +* +* box - Box plot. Options: +* raw - Set to true to supply pre-computed plot points as values +* values should be: low_outlier, low_whisker, q1, median, q3, high_whisker, high_outlier +* When set to false you can supply any number of values and the box plot will +* be computed for you. Default is false. +* showOutliers - Set to true (default) to display outliers as circles +* outlierIQR - Interquartile range used to determine outliers. Default 1.5 +* boxLineColor - Outline color of the box +* boxFillColor - Fill color for the box +* whiskerColor - Line color used for whiskers +* outlierLineColor - Outline color of outlier circles +* outlierFillColor - Fill color of the outlier circles +* spotRadius - Radius of outlier circles +* medianColor - Line color of the median line +* target - Draw a target cross hair at the supplied value (default undefined) +* +* +* +* Examples: +* $('#sparkline1').sparkline(myvalues, { lineColor: '#f00', fillColor: false }); +* $('.barsparks').sparkline('html', { type:'bar', height:'40px', barWidth:5 }); +* $('#tristate').sparkline([1,1,-1,1,0,0,-1], { type:'tristate' }): +* $('#discrete').sparkline([1,3,4,5,5,3,4,5], { type:'discrete' }); +* $('#bullet').sparkline([10,12,12,9,7], { type:'bullet' }); +* $('#pie').sparkline([1,1,2], { type:'pie' }); +*/ + +/*jslint regexp: true, browser: true, jquery: true, white: true, nomen: false, plusplus: false, maxerr: 500, indent: 4 */ + +(function(factory) { + if(typeof define === 'function' && define.amd) { + define(['jquery'], factory); + } + else { + factory(jQuery); + } +} +(function($) { + 'use strict'; + + var UNSET_OPTION = {}, + getDefaults, createClass, SPFormat, clipval, quartile, normalizeValue, normalizeValues, + remove, isNumber, all, sum, addCSS, ensureArray, formatNumber, RangeMap, + MouseHandler, Tooltip, barHighlightMixin, + line, bar, tristate, discrete, bullet, pie, box, defaultStyles, initStyles, + VShape, VCanvas_base, VCanvas_canvas, VCanvas_vml, pending, shapeCount = 0; + + /** + * Default configuration settings + */ + getDefaults = function () { + return { + // Settings common to most/all chart types + common: { + type: 'line', + lineColor: '#00f', + fillColor: '#cdf', + defaultPixelsPerValue: 3, + width: 'auto', + height: 'auto', + composite: false, + tagValuesAttribute: 'values', + tagOptionsPrefix: 'spark', + enableTagOptions: false, + enableHighlight: true, + highlightLighten: 1.4, + tooltipSkipNull: true, + tooltipPrefix: '', + tooltipSuffix: '', + disableHiddenCheck: false, + numberFormatter: false, + tooltips: false, + numberDigitGroupCount: 3, + numberDigitGroupSep: ',', + numberDecimalMark: '.', + disableTooltips: false, + disableInteraction: false + }, + // Defaults for line charts + line: { + spotColor: '#f80', + highlightSpotColor: '#5f5', + highlightLineColor: '#f22', + spotRadius: 1.5, + minSpotColor: '#f80', + maxSpotColor: '#f80', + lineWidth: 1, + normalRangeMin: undefined, + normalRangeMax: undefined, + normalRangeColor: '#ccc', + drawNormalOnTop: false, + chartRangeMin: undefined, + chartRangeMax: undefined, + chartRangeMinX: undefined, + chartRangeMaxX: undefined, + tooltipFormat: new SPFormat(' {{prefix}}{{y}}{{suffix}}') + }, + // Defaults for bar charts + bar: { + barColor: '#3366cc', + negBarColor: '#f44', + stackedBarColor: ['#3366cc', '#dc3912', '#ff9900', '#109618', '#66aa00', + '#dd4477', '#0099c6', '#990099'], + zeroColor: undefined, + nullColor: undefined, + zeroAxis: true, + barWidth: 4, + barSpacing: 1, + chartRangeMax: undefined, + chartRangeMin: undefined, + chartRangeClip: false, + colorMap: undefined, + tooltipFormat: new SPFormat(' {{prefix}}{{value}}{{suffix}}') + }, + // Defaults for tristate charts + tristate: { + barWidth: 4, + barSpacing: 1, + posBarColor: '#6f6', + negBarColor: '#f44', + zeroBarColor: '#999', + colorMap: {}, + tooltipFormat: new SPFormat(' {{value:map}}'), + tooltipValueLookups: { map: { '-1': 'Loss', '0': 'Draw', '1': 'Win' } } + }, + // Defaults for discrete charts + discrete: { + lineHeight: 'auto', + thresholdColor: undefined, + thresholdValue: 0, + chartRangeMax: undefined, + chartRangeMin: undefined, + chartRangeClip: false, + tooltipFormat: new SPFormat('{{prefix}}{{value}}{{suffix}}') + }, + // Defaults for bullet charts + bullet: { + targetColor: '#f33', + targetWidth: 3, // width of the target bar in pixels + performanceColor: '#33f', + rangeColors: ['#d3dafe', '#a8b6ff', '#7f94ff'], + base: undefined, // set this to a number to change the base start number + tooltipFormat: new SPFormat('{{fieldkey:fields}} - {{value}}'), + tooltipValueLookups: { fields: {r: 'Range', p: 'Performance', t: 'Target'} } + }, + // Defaults for pie charts + pie: { + offset: 0, + sliceColors: ['#3366cc', '#dc3912', '#ff9900', '#109618', '#66aa00', + '#dd4477', '#0099c6', '#990099'], + borderWidth: 0, + borderColor: '#000', + tooltipFormat: new SPFormat(' {{value}} ({{percent.1}}%)') + }, + // Defaults for box plots + box: { + raw: false, + boxLineColor: '#000', + boxFillColor: '#cdf', + whiskerColor: '#000', + outlierLineColor: '#333', + outlierFillColor: '#fff', + medianColor: '#f00', + showOutliers: true, + outlierIQR: 1.5, + spotRadius: 1.5, + target: undefined, + targetColor: '#4a2', + chartRangeMax: undefined, + chartRangeMin: undefined, + tooltipFormat: new SPFormat('{{field:fields}}: {{value}}'), + tooltipFormatFieldlistKey: 'field', + tooltipValueLookups: { fields: { lq: 'Lower Quartile', med: 'Median', + uq: 'Upper Quartile', lo: 'Left Outlier', ro: 'Right Outlier', + lw: 'Left Whisker', rw: 'Right Whisker'} } + } + }; + }; + + // You can have tooltips use a css class other than jqstooltip by specifying tooltipClassname + defaultStyles = '.jqstooltip { ' + + 'position: absolute;' + + 'left: 0px;' + + 'top: 0px;' + + 'visibility: hidden;' + + 'background: rgb(0, 0, 0) transparent;' + + 'background-color: rgba(0,0,0,0.6);' + + 'filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000);' + + '-ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000)";' + + 'color: white;' + + 'font: 10px arial, san serif;' + + 'text-align: left;' + + 'white-space: nowrap;' + + 'padding: 5px;' + + 'border: 1px solid white;' + + 'z-index: 10000;' + + '}' + + '.jqsfield { ' + + 'color: white;' + + 'font: 10px arial, san serif;' + + 'text-align: left;' + + '}'; + + /** + * Utilities + */ + + createClass = function (/* [baseclass, [mixin, ...]], definition */) { + var Class, args; + Class = function () { + this.init.apply(this, arguments); + }; + if (arguments.length > 1) { + if (arguments[0]) { + Class.prototype = $.extend(new arguments[0](), arguments[arguments.length - 1]); + Class._super = arguments[0].prototype; + } else { + Class.prototype = arguments[arguments.length - 1]; + } + if (arguments.length > 2) { + args = Array.prototype.slice.call(arguments, 1, -1); + args.unshift(Class.prototype); + $.extend.apply($, args); + } + } else { + Class.prototype = arguments[0]; + } + Class.prototype.cls = Class; + return Class; + }; + + /** + * Wraps a format string for tooltips + * {{x}} + * {{x.2} + * {{x:months}} + */ + $.SPFormatClass = SPFormat = createClass({ + fre: /\{\{([\w.]+?)(:(.+?))?\}\}/g, + precre: /(\w+)\.(\d+)/, + + init: function (format, fclass) { + this.format = format; + this.fclass = fclass; + }, + + render: function (fieldset, lookups, options) { + var self = this, + fields = fieldset, + match, token, lookupkey, fieldvalue, prec; + return this.format.replace(this.fre, function () { + var lookup; + token = arguments[1]; + lookupkey = arguments[3]; + match = self.precre.exec(token); + if (match) { + prec = match[2]; + token = match[1]; + } else { + prec = false; + } + fieldvalue = fields[token]; + if (fieldvalue === undefined) { + return ''; + } + if (lookupkey && lookups && lookups[lookupkey]) { + lookup = lookups[lookupkey]; + if (lookup.get) { // RangeMap + return lookups[lookupkey].get(fieldvalue) || fieldvalue; + } else { + return lookups[lookupkey][fieldvalue] || fieldvalue; + } + } + if (isNumber(fieldvalue)) { + if (options.get('tooltips')) { + var tooltipArray = options.get('tooltips').split(','); + fieldvalue = tooltipArray[ fields['x'] ]; + } + else if (options.get('numberFormatter')) { + fieldvalue = options.get('numberFormatter')(fieldvalue); + } else { + fieldvalue = formatNumber(fieldvalue, prec, + options.get('numberDigitGroupCount'), + options.get('numberDigitGroupSep'), + options.get('numberDecimalMark')); + } + } + return fieldvalue; + }); + } + }); + + // convience method to avoid needing the new operator + $.spformat = function(format, fclass) { + return new SPFormat(format, fclass); + }; + + clipval = function (val, min, max) { + if (val < min) { + return min; + } + if (val > max) { + return max; + } + return val; + }; + + quartile = function (values, q) { + var vl; + if (q === 2) { + vl = Math.floor(values.length / 2); + return values.length % 2 ? values[vl] : (values[vl-1] + values[vl]) / 2; + } else { + if (values.length % 2 ) { // odd + vl = (values.length * q + q) / 4; + return vl % 1 ? (values[Math.floor(vl)] + values[Math.floor(vl) - 1]) / 2 : values[vl-1]; + } else { //even + vl = (values.length * q + 2) / 4; + return vl % 1 ? (values[Math.floor(vl)] + values[Math.floor(vl) - 1]) / 2 : values[vl-1]; + + } + } + }; + + normalizeValue = function (val) { + var nf; + switch (val) { + case 'undefined': + val = undefined; + break; + case 'null': + val = null; + break; + case 'true': + val = true; + break; + case 'false': + val = false; + break; + default: + nf = parseFloat(val); + if (val == nf) { + val = nf; + } + } + return val; + }; + + normalizeValues = function (vals) { + var i, result = []; + for (i = vals.length; i--;) { + result[i] = normalizeValue(vals[i]); + } + return result; + }; + + remove = function (vals, filter) { + var i, vl, result = []; + for (i = 0, vl = vals.length; i < vl; i++) { + if (vals[i] !== filter) { + result.push(vals[i]); + } + } + return result; + }; + + isNumber = function (num) { + return !isNaN(parseFloat(num)) && isFinite(num); + }; + + formatNumber = function (num, prec, groupsize, groupsep, decsep) { + var p, i; + num = (prec === false ? parseFloat(num).toString() : num.toFixed(prec)).split(''); + p = (p = $.inArray('.', num)) < 0 ? num.length : p; + if (p < num.length) { + num[p] = decsep; + } + for (i = p - groupsize; i > 0; i -= groupsize) { + num.splice(i, 0, groupsep); + } + return num.join(''); + }; + + // determine if all values of an array match a value + // returns true if the array is empty + all = function (val, arr, ignoreNull) { + var i; + for (i = arr.length; i--; ) { + if (ignoreNull && arr[i] === null) continue; + if (arr[i] !== val) { + return false; + } + } + return true; + }; + + // sums the numeric values in an array, ignoring other values + sum = function (vals) { + var total = 0, i; + for (i = vals.length; i--;) { + total += typeof vals[i] === 'number' ? vals[i] : 0; + } + return total; + }; + + ensureArray = function (val) { + return $.isArray(val) ? val : [val]; + }; + + // http://paulirish.com/2008/bookmarklet-inject-new-css-rules/ + addCSS = function(css) { + var tag; + //if ('\v' == 'v') /* ie only */ { + if (document.createStyleSheet) { + document.createStyleSheet().cssText = css; + } else { + tag = document.createElement('style'); + tag.type = 'text/css'; + document.getElementsByTagName('head')[0].appendChild(tag); + tag[(typeof document.body.style.WebkitAppearance == 'string') /* webkit only */ ? 'innerText' : 'innerHTML'] = css; + } + }; + + // Provide a cross-browser interface to a few simple drawing primitives + $.fn.simpledraw = function (width, height, useExisting, interact) { + var target, mhandler; + if (useExisting && (target = this.data('_jqs_vcanvas'))) { + return target; + } + if (width === undefined) { + width = $(this).innerWidth(); + } + if (height === undefined) { + height = $(this).innerHeight(); + } + if ($.browser.hasCanvas) { + target = new VCanvas_canvas(width, height, this, interact); + } else if ($.browser.msie) { + target = new VCanvas_vml(width, height, this); + } else { + return false; + } + mhandler = $(this).data('_jqs_mhandler'); + if (mhandler) { + mhandler.registerCanvas(target); + } + return target; + }; + + $.fn.cleardraw = function () { + var target = this.data('_jqs_vcanvas'); + if (target) { + target.reset(); + } + }; + + $.RangeMapClass = RangeMap = createClass({ + init: function (map) { + var key, range, rangelist = []; + for (key in map) { + if (map.hasOwnProperty(key) && typeof key === 'string' && key.indexOf(':') > -1) { + range = key.split(':'); + range[0] = range[0].length === 0 ? -Infinity : parseFloat(range[0]); + range[1] = range[1].length === 0 ? Infinity : parseFloat(range[1]); + range[2] = map[key]; + rangelist.push(range); + } + } + this.map = map; + this.rangelist = rangelist || false; + }, + + get: function (value) { + var rangelist = this.rangelist, + i, range, result; + if ((result = this.map[value]) !== undefined) { + return result; + } + if (rangelist) { + for (i = rangelist.length; i--;) { + range = rangelist[i]; + if (range[0] <= value && range[1] >= value) { + return range[2]; + } + } + } + return undefined; + } + }); + + // Convenience function + $.range_map = function(map) { + return new RangeMap(map); + }; + + MouseHandler = createClass({ + init: function (el, options) { + var $el = $(el); + this.$el = $el; + this.options = options; + this.currentPageX = 0; + this.currentPageY = 0; + this.el = el; + this.splist = []; + this.tooltip = null; + this.over = false; + this.displayTooltips = !options.get('disableTooltips'); + this.highlightEnabled = !options.get('disableHighlight'); + }, + + registerSparkline: function (sp) { + this.splist.push(sp); + if (this.over) { + this.updateDisplay(); + } + }, + + registerCanvas: function (canvas) { + var $canvas = $(canvas.canvas); + this.canvas = canvas; + this.$canvas = $canvas; + $canvas.mouseenter($.proxy(this.mouseenter, this)); + $canvas.mouseleave($.proxy(this.mouseleave, this)); + $canvas.click($.proxy(this.mouseclick, this)); + }, + + reset: function (removeTooltip) { + this.splist = []; + if (this.tooltip && removeTooltip) { + this.tooltip.remove(); + this.tooltip = undefined; + } + }, + + mouseclick: function (e) { + var clickEvent = $.Event('sparklineClick'); + clickEvent.originalEvent = e; + clickEvent.sparklines = this.splist; + this.$el.trigger(clickEvent); + }, + + mouseenter: function (e) { + $(document.body).unbind('mousemove.jqs'); + $(document.body).bind('mousemove.jqs', $.proxy(this.mousemove, this)); + this.over = true; + this.currentPageX = e.pageX; + this.currentPageY = e.pageY; + this.currentEl = e.target; + if (!this.tooltip && this.displayTooltips) { + this.tooltip = new Tooltip(this.options); + this.tooltip.updatePosition(e.pageX, e.pageY); + } + this.updateDisplay(); + }, + + mouseleave: function () { + $(document.body).unbind('mousemove.jqs'); + var splist = this.splist, + spcount = splist.length, + needsRefresh = false, + sp, i; + this.over = false; + this.currentEl = null; + + if (this.tooltip) { + this.tooltip.remove(); + this.tooltip = null; + } + + for (i = 0; i < spcount; i++) { + sp = splist[i]; + if (sp.clearRegionHighlight()) { + needsRefresh = true; + } + } + + if (needsRefresh) { + this.canvas.render(); + } + }, + + mousemove: function (e) { + this.currentPageX = e.pageX; + this.currentPageY = e.pageY; + this.currentEl = e.target; + if (this.tooltip) { + this.tooltip.updatePosition(e.pageX, e.pageY); + } + this.updateDisplay(); + }, + + updateDisplay: function () { + var splist = this.splist, + spcount = splist.length, + needsRefresh = false, + offset = this.$canvas.offset(), + localX = this.currentPageX - offset.left, + localY = this.currentPageY - offset.top, + tooltiphtml, sp, i, result, changeEvent; + if (!this.over) { + return; + } + for (i = 0; i < spcount; i++) { + sp = splist[i]; + result = sp.setRegionHighlight(this.currentEl, localX, localY); + if (result) { + needsRefresh = true; + } + } + if (needsRefresh) { + changeEvent = $.Event('sparklineRegionChange'); + changeEvent.sparklines = this.splist; + this.$el.trigger(changeEvent); + if (this.tooltip) { + tooltiphtml = ''; + for (i = 0; i < spcount; i++) { + sp = splist[i]; + tooltiphtml += sp.getCurrentRegionTooltip(); + } + this.tooltip.setContent(tooltiphtml); + } + if (!this.disableHighlight) { + this.canvas.render(); + } + } + if (result === null) { + this.mouseleave(); + } + } + }); + + + Tooltip = createClass({ + sizeStyle: 'position: static !important;' + + 'display: block !important;' + + 'visibility: hidden !important;' + + 'float: left !important;', + + init: function (options) { + var tooltipClassname = options.get('tooltipClassname', 'jqstooltip'), + sizetipStyle = this.sizeStyle, + offset; + this.container = options.get('tooltipContainer') || document.body; + this.tooltipOffsetX = options.get('tooltipOffsetX', 10); + this.tooltipOffsetY = options.get('tooltipOffsetY', 12); + // remove any previous lingering tooltip + $('#jqssizetip').remove(); + $('#jqstooltip').remove(); + this.sizetip = $('
', { + id: 'jqssizetip', + style: sizetipStyle, + 'class': tooltipClassname + }); + this.tooltip = $('
', { + id: 'jqstooltip', + 'class': tooltipClassname + }).appendTo(this.container); + // account for the container's location + offset = this.tooltip.offset(); + this.offsetLeft = offset.left; + this.offsetTop = offset.top; + this.hidden = true; + $(window).unbind('resize.jqs scroll.jqs'); + $(window).bind('resize.jqs scroll.jqs', $.proxy(this.updateWindowDims, this)); + this.updateWindowDims(); + }, + + updateWindowDims: function () { + this.scrollTop = $(window).scrollTop(); + this.scrollLeft = $(window).scrollLeft(); + this.scrollRight = this.scrollLeft + $(window).width(); + this.updatePosition(); + }, + + getSize: function (content) { + this.sizetip.html(content).appendTo(this.container); + this.width = this.sizetip.width() + 1; + this.height = this.sizetip.height(); + this.sizetip.remove(); + }, + + setContent: function (content) { + if (!content) { + this.tooltip.css('visibility', 'hidden'); + this.hidden = true; + return; + } + this.getSize(content); + this.tooltip.html(content) + .css({ + 'width': this.width, + 'height': this.height, + 'visibility': 'visible' + }); + if (this.hidden) { + this.hidden = false; + this.updatePosition(); + } + }, + + updatePosition: function (x, y) { + if (x === undefined) { + if (this.mousex === undefined) { + return; + } + x = this.mousex - this.offsetLeft; + y = this.mousey - this.offsetTop; + + } else { + this.mousex = x = x - this.offsetLeft; + this.mousey = y = y - this.offsetTop; + } + if (!this.height || !this.width || this.hidden) { + return; + } + + y -= this.height + this.tooltipOffsetY; + x += this.tooltipOffsetX; + + if (y < this.scrollTop) { + y = this.scrollTop; + } + if (x < this.scrollLeft) { + x = this.scrollLeft; + } else if (x + this.width > this.scrollRight) { + x = this.scrollRight - this.width; + } + + this.tooltip.css({ + 'left': x, + 'top': y + }); + }, + + remove: function () { + this.tooltip.remove(); + this.sizetip.remove(); + this.sizetip = this.tooltip = undefined; + $(window).unbind('resize.jqs scroll.jqs'); + } + }); + + initStyles = function() { + addCSS(defaultStyles); + }; + + $(initStyles); + + pending = []; + $.fn.sparkline = function (userValues, userOptions) { + return this.each(function () { + var options = new $.fn.sparkline.options(this, userOptions), + $this = $(this), + render, i; + render = function () { + var values, width, height, tmp, mhandler, sp, vals; + if (userValues === 'html' || userValues === undefined) { + vals = this.getAttribute(options.get('tagValuesAttribute')); + if (vals === undefined || vals === null) { + vals = $this.html(); + } + values = vals.replace(/(^\s*\s*$)|\s+/g, '').split(','); + } else { + values = userValues; + } + + width = options.get('width') === 'auto' ? values.length * options.get('defaultPixelsPerValue') : options.get('width'); + if (options.get('height') === 'auto') { + if (!options.get('composite') || !$.data(this, '_jqs_vcanvas')) { + // must be a better way to get the line height + tmp = document.createElement('span'); + tmp.innerHTML = 'a'; + $this.html(tmp); + height = $(tmp).innerHeight() || $(tmp).height(); + $(tmp).remove(); + tmp = null; + } + } else { + height = options.get('height'); + } + + if (!options.get('disableInteraction')) { + mhandler = $.data(this, '_jqs_mhandler'); + if (!mhandler) { + mhandler = new MouseHandler(this, options); + $.data(this, '_jqs_mhandler', mhandler); + } else if (!options.get('composite')) { + mhandler.reset(); + } + } else { + mhandler = false; + } + + if (options.get('composite') && !$.data(this, '_jqs_vcanvas')) { + if (!$.data(this, '_jqs_errnotify')) { + alert('Attempted to attach a composite sparkline to an element with no existing sparkline'); + $.data(this, '_jqs_errnotify', true); + } + return; + } + + sp = new $.fn.sparkline[options.get('type')](this, values, options, width, height); + + sp.render(); + + if (mhandler) { + mhandler.registerSparkline(sp); + } + }; + // jQuery 1.3.0 completely changed the meaning of :hidden :-/ + if (($(this).html() && !options.get('disableHiddenCheck') && $(this).is(':hidden')) || ($.fn.jquery < '1.3.0' && $(this).parents().is(':hidden')) || !$(this).parents('body').length) { + if (!options.get('composite') && $.data(this, '_jqs_pending')) { + // remove any existing references to the element + for (i = pending.length; i; i--) { + if (pending[i - 1][0] == this) { + pending.splice(i - 1, 1); + } + } + } + pending.push([this, render]); + $.data(this, '_jqs_pending', true); + } else { + render.call(this); + } + }); + }; + + $.fn.sparkline.defaults = getDefaults(); + + + $.sparkline_display_visible = function () { + var el, i, pl; + var done = []; + for (i = 0, pl = pending.length; i < pl; i++) { + el = pending[i][0]; + if ($(el).is(':visible') && !$(el).parents().is(':hidden')) { + pending[i][1].call(el); + $.data(pending[i][0], '_jqs_pending', false); + done.push(i); + } else if (!$(el).closest('html').length && !$.data(el, '_jqs_pending')) { + // element has been inserted and removed from the DOM + // If it was not yet inserted into the dom then the .data request + // will return true. + // removing from the dom causes the data to be removed. + $.data(pending[i][0], '_jqs_pending', false); + done.push(i); + } + } + for (i = done.length; i; i--) { + pending.splice(done[i - 1], 1); + } + }; + + + /** + * User option handler + */ + $.fn.sparkline.options = createClass({ + init: function (tag, userOptions) { + var extendedOptions, defaults, base, tagOptionType; + this.userOptions = userOptions = userOptions || {}; + this.tag = tag; + this.tagValCache = {}; + defaults = $.fn.sparkline.defaults; + base = defaults.common; + this.tagOptionsPrefix = userOptions.enableTagOptions && (userOptions.tagOptionsPrefix || base.tagOptionsPrefix); + + tagOptionType = this.getTagSetting('type'); + if (tagOptionType === UNSET_OPTION) { + extendedOptions = defaults[userOptions.type || base.type]; + } else { + extendedOptions = defaults[tagOptionType]; + } + this.mergedOptions = $.extend({}, base, extendedOptions, userOptions); + }, + + + getTagSetting: function (key) { + var prefix = this.tagOptionsPrefix, + val, i, pairs, keyval; + if (prefix === false || prefix === undefined) { + return UNSET_OPTION; + } + if (this.tagValCache.hasOwnProperty(key)) { + val = this.tagValCache.key; + } else { + val = this.tag.getAttribute(prefix + key); + if (val === undefined || val === null) { + val = UNSET_OPTION; + } else if (val.substr(0, 1) === '[') { + val = val.substr(1, val.length - 2).split(','); + for (i = val.length; i--;) { + val[i] = normalizeValue(val[i].replace(/(^\s*)|(\s*$)/g, '')); + } + } else if (val.substr(0, 1) === '{') { + pairs = val.substr(1, val.length - 2).split(','); + val = {}; + for (i = pairs.length; i--;) { + keyval = pairs[i].split(':', 2); + val[keyval[0].replace(/(^\s*)|(\s*$)/g, '')] = normalizeValue(keyval[1].replace(/(^\s*)|(\s*$)/g, '')); + } + } else { + val = normalizeValue(val); + } + this.tagValCache.key = val; + } + return val; + }, + + get: function (key, defaultval) { + var tagOption = this.getTagSetting(key), + result; + if (tagOption !== UNSET_OPTION) { + return tagOption; + } + return (result = this.mergedOptions[key]) === undefined ? defaultval : result; + } + }); + + + $.fn.sparkline._base = createClass({ + disabled: false, + + init: function (el, values, options, width, height) { + this.el = el; + this.$el = $(el); + this.values = values; + this.options = options; + this.width = width; + this.height = height; + this.currentRegion = undefined; + }, + + /** + * Setup the canvas + */ + initTarget: function () { + var interactive = !this.options.get('disableInteraction'); + if (!(this.target = this.$el.simpledraw(this.width, this.height, this.options.get('composite'), interactive))) { + this.disabled = true; + } else { + this.canvasWidth = this.target.pixelWidth; + this.canvasHeight = this.target.pixelHeight; + } + }, + + /** + * Actually render the chart to the canvas + */ + render: function () { + if (this.disabled) { + this.el.innerHTML = ''; + return false; + } + return true; + }, + + /** + * Return a region id for a given x/y co-ordinate + */ + getRegion: function (x, y) { + }, + + /** + * Highlight an item based on the moused-over x,y co-ordinate + */ + setRegionHighlight: function (el, x, y) { + var currentRegion = this.currentRegion, + highlightEnabled = !this.options.get('disableHighlight'), + newRegion; + if (x > this.canvasWidth || y > this.canvasHeight || x < 0 || y < 0) { + return null; + } + newRegion = this.getRegion(el, x, y); + if (currentRegion !== newRegion) { + if (currentRegion !== undefined && highlightEnabled) { + this.removeHighlight(); + } + this.currentRegion = newRegion; + if (newRegion !== undefined && highlightEnabled) { + this.renderHighlight(); + } + return true; + } + return false; + }, + + /** + * Reset any currently highlighted item + */ + clearRegionHighlight: function () { + if (this.currentRegion !== undefined) { + this.removeHighlight(); + this.currentRegion = undefined; + return true; + } + return false; + }, + + renderHighlight: function () { + this.changeHighlight(true); + }, + + removeHighlight: function () { + this.changeHighlight(false); + }, + + changeHighlight: function (highlight) {}, + + /** + * Fetch the HTML to display as a tooltip + */ + getCurrentRegionTooltip: function () { + var options = this.options, + header = '', + entries = [], + fields, formats, formatlen, fclass, text, i, + showFields, showFieldsKey, newFields, fv, + formatter, format, fieldlen, j; + if (this.currentRegion === undefined) { + return ''; + } + fields = this.getCurrentRegionFields(); + formatter = options.get('tooltipFormatter'); + if (formatter) { + return formatter(this, options, fields); + } + if (options.get('tooltipChartTitle')) { + header += '
' + options.get('tooltipChartTitle') + '
\n'; + } + formats = this.options.get('tooltipFormat'); + if (!formats) { + return ''; + } + if (!$.isArray(formats)) { + formats = [formats]; + } + if (!$.isArray(fields)) { + fields = [fields]; + } + showFields = this.options.get('tooltipFormatFieldlist'); + showFieldsKey = this.options.get('tooltipFormatFieldlistKey'); + if (showFields && showFieldsKey) { + // user-selected ordering of fields + newFields = []; + for (i = fields.length; i--;) { + fv = fields[i][showFieldsKey]; + if ((j = $.inArray(fv, showFields)) != -1) { + newFields[j] = fields[i]; + } + } + fields = newFields; + } + formatlen = formats.length; + fieldlen = fields.length; + for (i = 0; i < formatlen; i++) { + format = formats[i]; + if (typeof format === 'string') { + format = new SPFormat(format); + } + fclass = format.fclass || 'jqsfield'; + for (j = 0; j < fieldlen; j++) { + if (!fields[j].isNull || !options.get('tooltipSkipNull')) { + $.extend(fields[j], { + prefix: options.get('tooltipPrefix'), + suffix: options.get('tooltipSuffix') + }); + text = format.render(fields[j], options.get('tooltipValueLookups'), options); + entries.push('
' + text + '
'); + } + } + } + if (entries.length) { + return header + entries.join('\n'); + } + return ''; + }, + + getCurrentRegionFields: function () {}, + + calcHighlightColor: function (color, options) { + var highlightColor = options.get('highlightColor'), + lighten = options.get('highlightLighten'), + parse, mult, rgbnew, i; + if (highlightColor) { + return highlightColor; + } + if (lighten) { + // extract RGB values + parse = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color) || /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color); + if (parse) { + rgbnew = []; + mult = color.length === 4 ? 16 : 1; + for (i = 0; i < 3; i++) { + rgbnew[i] = clipval(Math.round(parseInt(parse[i + 1], 16) * mult * lighten), 0, 255); + } + return 'rgb(' + rgbnew.join(',') + ')'; + } + + } + return color; + } + + }); + + barHighlightMixin = { + changeHighlight: function (highlight) { + var currentRegion = this.currentRegion, + target = this.target, + shapeids = this.regionShapes[currentRegion], + newShapes; + // will be null if the region value was null + if (shapeids) { + newShapes = this.renderRegion(currentRegion, highlight); + if ($.isArray(newShapes) || $.isArray(shapeids)) { + target.replaceWithShapes(shapeids, newShapes); + this.regionShapes[currentRegion] = $.map(newShapes, function (newShape) { + return newShape.id; + }); + } else { + target.replaceWithShape(shapeids, newShapes); + this.regionShapes[currentRegion] = newShapes.id; + } + } + }, + + render: function () { + var values = this.values, + target = this.target, + regionShapes = this.regionShapes, + shapes, ids, i, j; + + if (!this.cls._super.render.call(this)) { + return; + } + for (i = values.length; i--;) { + shapes = this.renderRegion(i); + if (shapes) { + if ($.isArray(shapes)) { + ids = []; + for (j = shapes.length; j--;) { + shapes[j].append(); + ids.push(shapes[j].id); + } + regionShapes[i] = ids; + } else { + shapes.append(); + regionShapes[i] = shapes.id; // store just the shapeid + } + } else { + // null value + regionShapes[i] = null; + } + } + target.render(); + } + }; + + /** + * Line charts + */ + $.fn.sparkline.line = line = createClass($.fn.sparkline._base, { + type: 'line', + + init: function (el, values, options, width, height) { + line._super.init.call(this, el, values, options, width, height); + this.vertices = []; + this.regionMap = []; + this.xvalues = []; + this.yvalues = []; + this.yminmax = []; + this.hightlightSpotId = null; + this.lastShapeId = null; + this.initTarget(); + }, + + getRegion: function (el, x, y) { + var i, + regionMap = this.regionMap; // maps regions to value positions + for (i = regionMap.length; i--;) { + if (regionMap[i] !== null && x >= regionMap[i][0] && x <= regionMap[i][1]) { + return regionMap[i][2]; + } + } + return undefined; + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion; + return { + isNull: this.yvalues[currentRegion] === null, + x: this.xvalues[currentRegion], + y: this.yvalues[currentRegion], + color: this.options.get('lineColor'), + fillColor: this.options.get('fillColor'), + offset: currentRegion + }; + }, + + renderHighlight: function () { + var currentRegion = this.currentRegion, + target = this.target, + vertex = this.vertices[currentRegion], + options = this.options, + spotRadius = options.get('spotRadius'), + highlightSpotColor = options.get('highlightSpotColor'), + highlightLineColor = options.get('highlightLineColor'), + highlightSpot, highlightLine; + + if (!vertex) { + return; + } + if (spotRadius && highlightSpotColor) { + highlightSpot = target.drawCircle(vertex[0], vertex[1], + spotRadius, undefined, highlightSpotColor); + this.highlightSpotId = highlightSpot.id; + target.insertAfterShape(this.lastShapeId, highlightSpot); + } + if (highlightLineColor) { + highlightLine = target.drawLine(vertex[0], this.canvasTop, vertex[0], + this.canvasTop + this.canvasHeight, highlightLineColor); + this.highlightLineId = highlightLine.id; + target.insertAfterShape(this.lastShapeId, highlightLine); + } + }, + + removeHighlight: function () { + var target = this.target; + if (this.highlightSpotId) { + target.removeShapeId(this.highlightSpotId); + this.highlightSpotId = null; + } + if (this.highlightLineId) { + target.removeShapeId(this.highlightLineId); + this.highlightLineId = null; + } + }, + + scanValues: function () { + var values = this.values, + valcount = values.length, + xvalues = this.xvalues, + yvalues = this.yvalues, + yminmax = this.yminmax, + i, val, isStr, isArray, sp; + for (i = 0; i < valcount; i++) { + val = values[i]; + isStr = typeof(values[i]) === 'string'; + isArray = typeof(values[i]) === 'object' && values[i] instanceof Array; + sp = isStr && values[i].split(':'); + if (isStr && sp.length === 2) { // x:y + xvalues.push(Number(sp[0])); + yvalues.push(Number(sp[1])); + yminmax.push(Number(sp[1])); + } else if (isArray) { + xvalues.push(val[0]); + yvalues.push(val[1]); + yminmax.push(val[1]); + } else { + xvalues.push(i); + if (values[i] === null || values[i] === 'null') { + yvalues.push(null); + } else { + yvalues.push(Number(val)); + yminmax.push(Number(val)); + } + } + } + if (this.options.get('xvalues')) { + xvalues = this.options.get('xvalues'); + } + + this.maxy = this.maxyorg = Math.max.apply(Math, yminmax); + this.miny = this.minyorg = Math.min.apply(Math, yminmax); + + this.maxx = Math.max.apply(Math, xvalues); + this.minx = Math.min.apply(Math, xvalues); + + this.xvalues = xvalues; + this.yvalues = yvalues; + this.yminmax = yminmax; + + }, + + processRangeOptions: function () { + var options = this.options, + normalRangeMin = options.get('normalRangeMin'), + normalRangeMax = options.get('normalRangeMax'); + + if (normalRangeMin !== undefined) { + if (normalRangeMin < this.miny) { + this.miny = normalRangeMin; + } + if (normalRangeMax > this.maxy) { + this.maxy = normalRangeMax; + } + } + if (options.get('chartRangeMin') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMin') < this.miny)) { + this.miny = options.get('chartRangeMin'); + } + if (options.get('chartRangeMax') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMax') > this.maxy)) { + this.maxy = options.get('chartRangeMax'); + } + if (options.get('chartRangeMinX') !== undefined && (options.get('chartRangeClipX') || options.get('chartRangeMinX') < this.minx)) { + this.minx = options.get('chartRangeMinX'); + } + if (options.get('chartRangeMaxX') !== undefined && (options.get('chartRangeClipX') || options.get('chartRangeMaxX') > this.maxx)) { + this.maxx = options.get('chartRangeMaxX'); + } + + }, + + drawNormalRange: function (canvasLeft, canvasTop, canvasHeight, canvasWidth, rangey) { + var normalRangeMin = this.options.get('normalRangeMin'), + normalRangeMax = this.options.get('normalRangeMax'), + ytop = canvasTop + Math.round(canvasHeight - (canvasHeight * ((normalRangeMax - this.miny) / rangey))), + height = Math.round((canvasHeight * (normalRangeMax - normalRangeMin)) / rangey); + this.target.drawRect(canvasLeft, ytop, canvasWidth, height, undefined, this.options.get('normalRangeColor')).append(); + }, + + render: function () { + var options = this.options, + target = this.target, + canvasWidth = this.canvasWidth, + canvasHeight = this.canvasHeight, + vertices = this.vertices, + spotRadius = options.get('spotRadius'), + regionMap = this.regionMap, + rangex, rangey, yvallast, + canvasTop, canvasLeft, + vertex, path, paths, x, y, xnext, xpos, xposnext, + last, next, yvalcount, lineShapes, fillShapes, plen, + valueSpots, hlSpotsEnabled, color, xvalues, yvalues, i; + + if (!line._super.render.call(this)) { + return; + } + + this.scanValues(); + this.processRangeOptions(); + + xvalues = this.xvalues; + yvalues = this.yvalues; + + if (!this.yminmax.length || this.yvalues.length < 2) { + // empty or all null valuess + return; + } + + canvasTop = canvasLeft = 0; + + rangex = this.maxx - this.minx === 0 ? 1 : this.maxx - this.minx; + rangey = this.maxy - this.miny === 0 ? 1 : this.maxy - this.miny; + yvallast = this.yvalues.length - 1; + + if (spotRadius && (canvasWidth < (spotRadius * 4) || canvasHeight < (spotRadius * 4))) { + spotRadius = 0; + } + if (spotRadius) { + // adjust the canvas size as required so that spots will fit + hlSpotsEnabled = options.get('highlightSpotColor') && !options.get('disableInteraction'); + if (hlSpotsEnabled || options.get('minSpotColor') || (options.get('spotColor') && yvalues[yvallast] === this.miny)) { + canvasHeight -= Math.ceil(spotRadius); + } + if (hlSpotsEnabled || options.get('maxSpotColor') || (options.get('spotColor') && yvalues[yvallast] === this.maxy)) { + canvasHeight -= Math.ceil(spotRadius); + canvasTop += Math.ceil(spotRadius); + } + if (hlSpotsEnabled || + ((options.get('minSpotColor') || options.get('maxSpotColor')) && (yvalues[0] === this.miny || yvalues[0] === this.maxy))) { + canvasLeft += Math.ceil(spotRadius); + canvasWidth -= Math.ceil(spotRadius); + } + if (hlSpotsEnabled || options.get('spotColor') || + (options.get('minSpotColor') || options.get('maxSpotColor') && + (yvalues[yvallast] === this.miny || yvalues[yvallast] === this.maxy))) { + canvasWidth -= Math.ceil(spotRadius); + } + } + + + canvasHeight--; + + if (options.get('normalRangeMin') !== undefined && !options.get('drawNormalOnTop')) { + this.drawNormalRange(canvasLeft, canvasTop, canvasHeight, canvasWidth, rangey); + } + + path = []; + paths = [path]; + last = next = null; + yvalcount = yvalues.length; + for (i = 0; i < yvalcount; i++) { + x = xvalues[i]; + xnext = xvalues[i + 1]; + y = yvalues[i]; + xpos = canvasLeft + Math.round((x - this.minx) * (canvasWidth / rangex)); + xposnext = i < yvalcount - 1 ? canvasLeft + Math.round((xnext - this.minx) * (canvasWidth / rangex)) : canvasWidth; + next = xpos + ((xposnext - xpos) / 2); + regionMap[i] = [last || 0, next, i]; + last = next; + if (y === null) { + if (i) { + if (yvalues[i - 1] !== null) { + path = []; + paths.push(path); + } + vertices.push(null); + } + } else { + if (y < this.miny) { + y = this.miny; + } + if (y > this.maxy) { + y = this.maxy; + } + if (!path.length) { + // previous value was null + path.push([xpos, canvasTop + canvasHeight]); + } + vertex = [xpos, canvasTop + Math.round(canvasHeight - (canvasHeight * ((y - this.miny) / rangey)))]; + path.push(vertex); + vertices.push(vertex); + } + } + + lineShapes = []; + fillShapes = []; + plen = paths.length; + for (i = 0; i < plen; i++) { + path = paths[i]; + if (path.length) { + if (options.get('fillColor')) { + path.push([path[path.length - 1][0], (canvasTop + canvasHeight)]); + fillShapes.push(path.slice(0)); + path.pop(); + } + // if there's only a single point in this path, then we want to display it + // as a vertical line which means we keep path[0] as is + if (path.length > 2) { + // else we want the first value + path[0] = [path[0][0], path[1][1]]; + } + lineShapes.push(path); + } + } + + // draw the fill first, then optionally the normal range, then the line on top of that + plen = fillShapes.length; + for (i = 0; i < plen; i++) { + target.drawShape(fillShapes[i], + options.get('fillColor'), options.get('fillColor')).append(); + } + + if (options.get('normalRangeMin') !== undefined && options.get('drawNormalOnTop')) { + this.drawNormalRange(canvasLeft, canvasTop, canvasHeight, canvasWidth, rangey); + } + + plen = lineShapes.length; + for (i = 0; i < plen; i++) { + target.drawShape(lineShapes[i], options.get('lineColor'), undefined, + options.get('lineWidth')).append(); + } + + if (spotRadius && options.get('valueSpots')) { + valueSpots = options.get('valueSpots'); + if (valueSpots.get === undefined) { + valueSpots = new RangeMap(valueSpots); + } + for (i = 0; i < yvalcount; i++) { + color = valueSpots.get(yvalues[i]); + if (color) { + target.drawCircle(canvasLeft + Math.round((xvalues[i] - this.minx) * (canvasWidth / rangex)), + canvasTop + Math.round(canvasHeight - (canvasHeight * ((yvalues[i] - this.miny) / rangey))), + spotRadius, undefined, + color).append(); + } + } + + } + if (spotRadius && options.get('spotColor')) { + target.drawCircle(canvasLeft + Math.round((xvalues[xvalues.length - 1] - this.minx) * (canvasWidth / rangex)), + canvasTop + Math.round(canvasHeight - (canvasHeight * ((yvalues[yvallast] - this.miny) / rangey))), + spotRadius, undefined, + options.get('spotColor')).append(); + } + if (this.maxy !== this.minyorg) { + if (spotRadius && options.get('minSpotColor')) { + x = xvalues[$.inArray(this.minyorg, yvalues)]; + target.drawCircle(canvasLeft + Math.round((x - this.minx) * (canvasWidth / rangex)), + canvasTop + Math.round(canvasHeight - (canvasHeight * ((this.minyorg - this.miny) / rangey))), + spotRadius, undefined, + options.get('minSpotColor')).append(); + } + if (spotRadius && options.get('maxSpotColor')) { + x = xvalues[$.inArray(this.maxyorg, yvalues)]; + target.drawCircle(canvasLeft + Math.round((x - this.minx) * (canvasWidth / rangex)), + canvasTop + Math.round(canvasHeight - (canvasHeight * ((this.maxyorg - this.miny) / rangey))), + spotRadius, undefined, + options.get('maxSpotColor')).append(); + } + } + + this.lastShapeId = target.getLastShapeId(); + this.canvasTop = canvasTop; + target.render(); + } + }); + + /** + * Bar charts + */ + $.fn.sparkline.bar = bar = createClass($.fn.sparkline._base, barHighlightMixin, { + type: 'bar', + + init: function (el, values, options, width, height) { + var barWidth = parseInt(options.get('barWidth'), 10), + barSpacing = parseInt(options.get('barSpacing'), 10), + chartRangeMin = options.get('chartRangeMin'), + chartRangeMax = options.get('chartRangeMax'), + chartRangeClip = options.get('chartRangeClip'), + stackMin = Infinity, + stackMax = -Infinity, + isStackString, groupMin, groupMax, stackRanges, + numValues, i, vlen, range, zeroAxis, xaxisOffset, min, max, clipMin, clipMax, + stacked, vlist, j, slen, svals, val, yoffset, yMaxCalc, canvasHeightEf; + bar._super.init.call(this, el, values, options, width, height); + + // scan values to determine whether to stack bars + for (i = 0, vlen = values.length; i < vlen; i++) { + val = values[i]; + isStackString = typeof(val) === 'string' && val.indexOf(':') > -1; + if (isStackString || $.isArray(val)) { + stacked = true; + if (isStackString) { + val = values[i] = normalizeValues(val.split(':')); + } + val = remove(val, null); // min/max will treat null as zero + groupMin = Math.min.apply(Math, val); + groupMax = Math.max.apply(Math, val); + if (groupMin < stackMin) { + stackMin = groupMin; + } + if (groupMax > stackMax) { + stackMax = groupMax; + } + } + } + + this.stacked = stacked; + this.regionShapes = {}; + this.barWidth = barWidth; + this.barSpacing = barSpacing; + this.totalBarWidth = barWidth + barSpacing; + this.width = width = (values.length * barWidth) + ((values.length - 1) * barSpacing); + + this.initTarget(); + + if (chartRangeClip) { + clipMin = chartRangeMin === undefined ? -Infinity : chartRangeMin; + clipMax = chartRangeMax === undefined ? Infinity : chartRangeMax; + } + + numValues = []; + stackRanges = stacked ? [] : numValues; + var stackTotals = []; + var stackRangesNeg = []; + for (i = 0, vlen = values.length; i < vlen; i++) { + if (stacked) { + vlist = values[i]; + values[i] = svals = []; + stackTotals[i] = 0; + stackRanges[i] = stackRangesNeg[i] = 0; + for (j = 0, slen = vlist.length; j < slen; j++) { + val = svals[j] = chartRangeClip ? clipval(vlist[j], clipMin, clipMax) : vlist[j]; + if (val !== null) { + if (val > 0) { + stackTotals[i] += val; + } + if (stackMin < 0 && stackMax > 0) { + if (val < 0) { + stackRangesNeg[i] += Math.abs(val); + } else { + stackRanges[i] += val; + } + } else { + stackRanges[i] += Math.abs(val - (val < 0 ? stackMax : stackMin)); + } + numValues.push(val); + } + } + } else { + val = chartRangeClip ? clipval(values[i], clipMin, clipMax) : values[i]; + val = values[i] = normalizeValue(val); + if (val !== null) { + numValues.push(val); + } + } + } + this.max = max = Math.max.apply(Math, numValues); + this.min = min = Math.min.apply(Math, numValues); + this.stackMax = stackMax = stacked ? Math.max.apply(Math, stackTotals) : max; + this.stackMin = stackMin = stacked ? Math.min.apply(Math, numValues) : min; + + if (options.get('chartRangeMin') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMin') < min)) { + min = options.get('chartRangeMin'); + } + if (options.get('chartRangeMax') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMax') > max)) { + max = options.get('chartRangeMax'); + } + + this.zeroAxis = zeroAxis = options.get('zeroAxis', true); + if (min <= 0 && max >= 0 && zeroAxis) { + xaxisOffset = 0; + } else if (zeroAxis == false) { + xaxisOffset = min; + } else if (min > 0) { + xaxisOffset = min; + } else { + xaxisOffset = max; + } + this.xaxisOffset = xaxisOffset; + + range = stacked ? (Math.max.apply(Math, stackRanges) + Math.max.apply(Math, stackRangesNeg)) : max - min; + + // as we plot zero/min values a single pixel line, we add a pixel to all other + // values - Reduce the effective canvas size to suit + this.canvasHeightEf = (zeroAxis && min < 0) ? this.canvasHeight - 2 : this.canvasHeight - 1; + + if (min < xaxisOffset) { + yMaxCalc = (stacked && max >= 0) ? stackMax : max; + yoffset = (yMaxCalc - xaxisOffset) / range * this.canvasHeight; + if (yoffset !== Math.ceil(yoffset)) { + this.canvasHeightEf -= 2; + yoffset = Math.ceil(yoffset); + } + } else { + yoffset = this.canvasHeight; + } + this.yoffset = yoffset; + + if ($.isArray(options.get('colorMap'))) { + this.colorMapByIndex = options.get('colorMap'); + this.colorMapByValue = null; + } else { + this.colorMapByIndex = null; + this.colorMapByValue = options.get('colorMap'); + if (this.colorMapByValue && this.colorMapByValue.get === undefined) { + this.colorMapByValue = new RangeMap(this.colorMapByValue); + } + } + + this.range = range; + }, + + getRegion: function (el, x, y) { + var result = Math.floor(x / this.totalBarWidth); + return (result < 0 || result >= this.values.length) ? undefined : result; + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion, + values = ensureArray(this.values[currentRegion]), + result = [], + value, i; + for (i = values.length; i--;) { + value = values[i]; + result.push({ + isNull: value === null, + value: value, + color: this.calcColor(i, value, currentRegion), + offset: currentRegion + }); + } + return result; + }, + + calcColor: function (stacknum, value, valuenum) { + var colorMapByIndex = this.colorMapByIndex, + colorMapByValue = this.colorMapByValue, + options = this.options, + color, newColor; + if (this.stacked) { + color = options.get('stackedBarColor'); + } else { + color = (value < 0) ? options.get('negBarColor') : options.get('barColor'); + } + if (value === 0 && options.get('zeroColor') !== undefined) { + color = options.get('zeroColor'); + } + if (colorMapByValue && (newColor = colorMapByValue.get(value))) { + color = newColor; + } else if (colorMapByIndex && colorMapByIndex.length > valuenum) { + color = colorMapByIndex[valuenum]; + } + return $.isArray(color) ? color[stacknum % color.length] : color; + }, + + /** + * Render bar(s) for a region + */ + renderRegion: function (valuenum, highlight) { + var vals = this.values[valuenum], + options = this.options, + xaxisOffset = this.xaxisOffset, + result = [], + range = this.range, + stacked = this.stacked, + target = this.target, + x = valuenum * this.totalBarWidth, + canvasHeightEf = this.canvasHeightEf, + yoffset = this.yoffset, + y, height, color, isNull, yoffsetNeg, i, valcount, val, minPlotted, allMin; + + vals = $.isArray(vals) ? vals : [vals]; + valcount = vals.length; + val = vals[0]; + isNull = all(null, vals); + allMin = all(xaxisOffset, vals, true); + + if (isNull) { + if (options.get('nullColor')) { + color = highlight ? options.get('nullColor') : this.calcHighlightColor(options.get('nullColor'), options); + y = (yoffset > 0) ? yoffset - 1 : yoffset; + return target.drawRect(x, y, this.barWidth - 1, 0, color, color); + } else { + return undefined; + } + } + yoffsetNeg = yoffset; + for (i = 0; i < valcount; i++) { + val = vals[i]; + + if (stacked && val === xaxisOffset) { + if (!allMin || minPlotted) { + continue; + } + minPlotted = true; + } + + if (range > 0) { + height = Math.floor(canvasHeightEf * ((Math.abs(val - xaxisOffset) / range))) + 1; + } else { + height = 1; + } + if (val < xaxisOffset || (val === xaxisOffset && yoffset === 0)) { + y = yoffsetNeg; + yoffsetNeg += height; + } else { + y = yoffset - height; + yoffset -= height; + } + color = this.calcColor(i, val, valuenum); + if (highlight) { + color = this.calcHighlightColor(color, options); + } + result.push(target.drawRect(x, y, this.barWidth - 1, height - 1, color, color)); + } + if (result.length === 1) { + return result[0]; + } + return result; + } + }); + + /** + * Tristate charts + */ + $.fn.sparkline.tristate = tristate = createClass($.fn.sparkline._base, barHighlightMixin, { + type: 'tristate', + + init: function (el, values, options, width, height) { + var barWidth = parseInt(options.get('barWidth'), 10), + barSpacing = parseInt(options.get('barSpacing'), 10); + tristate._super.init.call(this, el, values, options, width, height); + + this.regionShapes = {}; + this.barWidth = barWidth; + this.barSpacing = barSpacing; + this.totalBarWidth = barWidth + barSpacing; + this.values = $.map(values, Number); + this.width = width = (values.length * barWidth) + ((values.length - 1) * barSpacing); + + if ($.isArray(options.get('colorMap'))) { + this.colorMapByIndex = options.get('colorMap'); + this.colorMapByValue = null; + } else { + this.colorMapByIndex = null; + this.colorMapByValue = options.get('colorMap'); + if (this.colorMapByValue && this.colorMapByValue.get === undefined) { + this.colorMapByValue = new RangeMap(this.colorMapByValue); + } + } + this.initTarget(); + }, + + getRegion: function (el, x, y) { + return Math.floor(x / this.totalBarWidth); + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion; + return { + isNull: this.values[currentRegion] === undefined, + value: this.values[currentRegion], + color: this.calcColor(this.values[currentRegion], currentRegion), + offset: currentRegion + }; + }, + + calcColor: function (value, valuenum) { + var values = this.values, + options = this.options, + colorMapByIndex = this.colorMapByIndex, + colorMapByValue = this.colorMapByValue, + color, newColor; + + if (colorMapByValue && (newColor = colorMapByValue.get(value))) { + color = newColor; + } else if (colorMapByIndex && colorMapByIndex.length > valuenum) { + color = colorMapByIndex[valuenum]; + } else if (values[valuenum] < 0) { + color = options.get('negBarColor'); + } else if (values[valuenum] > 0) { + color = options.get('posBarColor'); + } else { + color = options.get('zeroBarColor'); + } + return color; + }, + + renderRegion: function (valuenum, highlight) { + var values = this.values, + options = this.options, + target = this.target, + canvasHeight, height, halfHeight, + x, y, color; + + canvasHeight = target.pixelHeight; + halfHeight = Math.round(canvasHeight / 2); + + x = valuenum * this.totalBarWidth; + if (values[valuenum] < 0) { + y = halfHeight; + height = halfHeight - 1; + } else if (values[valuenum] > 0) { + y = 0; + height = halfHeight - 1; + } else { + y = halfHeight - 1; + height = 2; + } + color = this.calcColor(values[valuenum], valuenum); + if (color === null) { + return; + } + if (highlight) { + color = this.calcHighlightColor(color, options); + } + return target.drawRect(x, y, this.barWidth - 1, height - 1, color, color); + } + }); + + /** + * Discrete charts + */ + $.fn.sparkline.discrete = discrete = createClass($.fn.sparkline._base, barHighlightMixin, { + type: 'discrete', + + init: function (el, values, options, width, height) { + discrete._super.init.call(this, el, values, options, width, height); + + this.regionShapes = {}; + this.values = values = $.map(values, Number); + this.min = Math.min.apply(Math, values); + this.max = Math.max.apply(Math, values); + this.range = this.max - this.min; + this.width = width = options.get('width') === 'auto' ? values.length * 2 : this.width; + this.interval = Math.floor(width / values.length); + this.itemWidth = width / values.length; + if (options.get('chartRangeMin') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMin') < this.min)) { + this.min = options.get('chartRangeMin'); + } + if (options.get('chartRangeMax') !== undefined && (options.get('chartRangeClip') || options.get('chartRangeMax') > this.max)) { + this.max = options.get('chartRangeMax'); + } + this.initTarget(); + if (this.target) { + this.lineHeight = options.get('lineHeight') === 'auto' ? Math.round(this.canvasHeight * 0.3) : options.get('lineHeight'); + } + }, + + getRegion: function (el, x, y) { + return Math.floor(x / this.itemWidth); + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion; + return { + isNull: this.values[currentRegion] === undefined, + value: this.values[currentRegion], + offset: currentRegion + }; + }, + + renderRegion: function (valuenum, highlight) { + var values = this.values, + options = this.options, + min = this.min, + max = this.max, + range = this.range, + interval = this.interval, + target = this.target, + canvasHeight = this.canvasHeight, + lineHeight = this.lineHeight, + pheight = canvasHeight - lineHeight, + ytop, val, color, x; + + val = clipval(values[valuenum], min, max); + x = valuenum * interval; + ytop = Math.round(pheight - pheight * ((val - min) / range)); + color = (options.get('thresholdColor') && val < options.get('thresholdValue')) ? options.get('thresholdColor') : options.get('lineColor'); + if (highlight) { + color = this.calcHighlightColor(color, options); + } + return target.drawLine(x, ytop, x, ytop + lineHeight, color); + } + }); + + /** + * Bullet charts + */ + $.fn.sparkline.bullet = bullet = createClass($.fn.sparkline._base, { + type: 'bullet', + + init: function (el, values, options, width, height) { + var min, max, vals; + bullet._super.init.call(this, el, values, options, width, height); + + // values: target, performance, range1, range2, range3 + this.values = values = normalizeValues(values); + // target or performance could be null + vals = values.slice(); + vals[0] = vals[0] === null ? vals[2] : vals[0]; + vals[1] = values[1] === null ? vals[2] : vals[1]; + min = Math.min.apply(Math, values); + max = Math.max.apply(Math, values); + if (options.get('base') === undefined) { + min = min < 0 ? min : 0; + } else { + min = options.get('base'); + } + this.min = min; + this.max = max; + this.range = max - min; + this.shapes = {}; + this.valueShapes = {}; + this.regiondata = {}; + this.width = width = options.get('width') === 'auto' ? '4.0em' : width; + this.target = this.$el.simpledraw(width, height, options.get('composite')); + if (!values.length) { + this.disabled = true; + } + this.initTarget(); + }, + + getRegion: function (el, x, y) { + var shapeid = this.target.getShapeAt(el, x, y); + return (shapeid !== undefined && this.shapes[shapeid] !== undefined) ? this.shapes[shapeid] : undefined; + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion; + return { + fieldkey: currentRegion.substr(0, 1), + value: this.values[currentRegion.substr(1)], + region: currentRegion + }; + }, + + changeHighlight: function (highlight) { + var currentRegion = this.currentRegion, + shapeid = this.valueShapes[currentRegion], + shape; + delete this.shapes[shapeid]; + switch (currentRegion.substr(0, 1)) { + case 'r': + shape = this.renderRange(currentRegion.substr(1), highlight); + break; + case 'p': + shape = this.renderPerformance(highlight); + break; + case 't': + shape = this.renderTarget(highlight); + break; + } + this.valueShapes[currentRegion] = shape.id; + this.shapes[shape.id] = currentRegion; + this.target.replaceWithShape(shapeid, shape); + }, + + renderRange: function (rn, highlight) { + var rangeval = this.values[rn], + rangewidth = Math.round(this.canvasWidth * ((rangeval - this.min) / this.range)), + color = this.options.get('rangeColors')[rn - 2]; + if (highlight) { + color = this.calcHighlightColor(color, this.options); + } + return this.target.drawRect(0, 0, rangewidth - 1, this.canvasHeight - 1, color, color); + }, + + renderPerformance: function (highlight) { + var perfval = this.values[1], + perfwidth = Math.round(this.canvasWidth * ((perfval - this.min) / this.range)), + color = this.options.get('performanceColor'); + if (highlight) { + color = this.calcHighlightColor(color, this.options); + } + return this.target.drawRect(0, Math.round(this.canvasHeight * 0.3), perfwidth - 1, + Math.round(this.canvasHeight * 0.4) - 1, color, color); + }, + + renderTarget: function (highlight) { + var targetval = this.values[0], + x = Math.round(this.canvasWidth * ((targetval - this.min) / this.range) - (this.options.get('targetWidth') / 2)), + targettop = Math.round(this.canvasHeight * 0.10), + targetheight = this.canvasHeight - (targettop * 2), + color = this.options.get('targetColor'); + if (highlight) { + color = this.calcHighlightColor(color, this.options); + } + return this.target.drawRect(x, targettop, this.options.get('targetWidth') - 1, targetheight - 1, color, color); + }, + + render: function () { + var vlen = this.values.length, + target = this.target, + i, shape; + if (!bullet._super.render.call(this)) { + return; + } + for (i = 2; i < vlen; i++) { + shape = this.renderRange(i).append(); + this.shapes[shape.id] = 'r' + i; + this.valueShapes['r' + i] = shape.id; + } + if (this.values[1] !== null) { + shape = this.renderPerformance().append(); + this.shapes[shape.id] = 'p1'; + this.valueShapes.p1 = shape.id; + } + if (this.values[0] !== null) { + shape = this.renderTarget().append(); + this.shapes[shape.id] = 't0'; + this.valueShapes.t0 = shape.id; + } + target.render(); + } + }); + + /** + * Pie charts + */ + $.fn.sparkline.pie = pie = createClass($.fn.sparkline._base, { + type: 'pie', + + init: function (el, values, options, width, height) { + var total = 0, i; + + pie._super.init.call(this, el, values, options, width, height); + + this.shapes = {}; // map shape ids to value offsets + this.valueShapes = {}; // maps value offsets to shape ids + this.values = values = $.map(values, Number); + + if (options.get('width') === 'auto') { + this.width = this.height; + } + + if (values.length > 0) { + for (i = values.length; i--;) { + total += values[i]; + } + } + this.total = total; + this.initTarget(); + this.radius = Math.floor(Math.min(this.canvasWidth, this.canvasHeight) / 2); + }, + + getRegion: function (el, x, y) { + var shapeid = this.target.getShapeAt(el, x, y); + return (shapeid !== undefined && this.shapes[shapeid] !== undefined) ? this.shapes[shapeid] : undefined; + }, + + getCurrentRegionFields: function () { + var currentRegion = this.currentRegion; + return { + isNull: this.values[currentRegion] === undefined, + value: this.values[currentRegion], + percent: this.values[currentRegion] / this.total * 100, + color: this.options.get('sliceColors')[currentRegion % this.options.get('sliceColors').length], + offset: currentRegion + }; + }, + + changeHighlight: function (highlight) { + var currentRegion = this.currentRegion, + newslice = this.renderSlice(currentRegion, highlight), + shapeid = this.valueShapes[currentRegion]; + delete this.shapes[shapeid]; + this.target.replaceWithShape(shapeid, newslice); + this.valueShapes[currentRegion] = newslice.id; + this.shapes[newslice.id] = currentRegion; + }, + + renderSlice: function (valuenum, highlight) { + var target = this.target, + options = this.options, + radius = this.radius, + borderWidth = options.get('borderWidth'), + offset = options.get('offset'), + circle = 2 * Math.PI, + values = this.values, + total = this.total, + next = offset ? (2*Math.PI)*(offset/360) : 0, + start, end, i, vlen, color; + + vlen = values.length; + for (i = 0; i < vlen; i++) { + start = next; + end = next; + if (total > 0) { // avoid divide by zero + end = next + (circle * (values[i] / total)); + } + if (valuenum === i) { + color = options.get('sliceColors')[i % options.get('sliceColors').length]; + if (highlight) { + color = this.calcHighlightColor(color, options); + } + + return target.drawPieSlice(radius, radius, radius - borderWidth, start, end, undefined, color); + } + next = end; + } + }, + + render: function () { + var target = this.target, + values = this.values, + options = this.options, + radius = this.radius, + borderWidth = options.get('borderWidth'), + shape, i; + + if (!pie._super.render.call(this)) { + return; + } + if (borderWidth) { + target.drawCircle(radius, radius, Math.floor(radius - (borderWidth / 2)), + options.get('borderColor'), undefined, borderWidth).append(); + } + for (i = values.length; i--;) { + if (values[i]) { // don't render zero values + shape = this.renderSlice(i).append(); + this.valueShapes[i] = shape.id; // store just the shapeid + this.shapes[shape.id] = i; + } + } + target.render(); + } + }); + + /** + * Box plots + */ + $.fn.sparkline.box = box = createClass($.fn.sparkline._base, { + type: 'box', + + init: function (el, values, options, width, height) { + box._super.init.call(this, el, values, options, width, height); + this.values = $.map(values, Number); + this.width = options.get('width') === 'auto' ? '4.0em' : width; + this.initTarget(); + if (!this.values.length) { + this.disabled = 1; + } + }, + + /** + * Simulate a single region + */ + getRegion: function () { + return 1; + }, + + getCurrentRegionFields: function () { + var result = [ + { field: 'lq', value: this.quartiles[0] }, + { field: 'med', value: this.quartiles[1] }, + { field: 'uq', value: this.quartiles[2] } + ]; + if (this.loutlier !== undefined) { + result.push({ field: 'lo', value: this.loutlier}); + } + if (this.routlier !== undefined) { + result.push({ field: 'ro', value: this.routlier}); + } + if (this.lwhisker !== undefined) { + result.push({ field: 'lw', value: this.lwhisker}); + } + if (this.rwhisker !== undefined) { + result.push({ field: 'rw', value: this.rwhisker}); + } + return result; + }, + + render: function () { + var target = this.target, + values = this.values, + vlen = values.length, + options = this.options, + canvasWidth = this.canvasWidth, + canvasHeight = this.canvasHeight, + minValue = options.get('chartRangeMin') === undefined ? Math.min.apply(Math, values) : options.get('chartRangeMin'), + maxValue = options.get('chartRangeMax') === undefined ? Math.max.apply(Math, values) : options.get('chartRangeMax'), + canvasLeft = 0, + lwhisker, loutlier, iqr, q1, q2, q3, rwhisker, routlier, i, + size, unitSize; + + if (!box._super.render.call(this)) { + return; + } + + if (options.get('raw')) { + if (options.get('showOutliers') && values.length > 5) { + loutlier = values[0]; + lwhisker = values[1]; + q1 = values[2]; + q2 = values[3]; + q3 = values[4]; + rwhisker = values[5]; + routlier = values[6]; + } else { + lwhisker = values[0]; + q1 = values[1]; + q2 = values[2]; + q3 = values[3]; + rwhisker = values[4]; + } + } else { + values.sort(function (a, b) { return a - b; }); + q1 = quartile(values, 1); + q2 = quartile(values, 2); + q3 = quartile(values, 3); + iqr = q3 - q1; + if (options.get('showOutliers')) { + lwhisker = rwhisker = undefined; + for (i = 0; i < vlen; i++) { + if (lwhisker === undefined && values[i] > q1 - (iqr * options.get('outlierIQR'))) { + lwhisker = values[i]; + } + if (values[i] < q3 + (iqr * options.get('outlierIQR'))) { + rwhisker = values[i]; + } + } + loutlier = values[0]; + routlier = values[vlen - 1]; + } else { + lwhisker = values[0]; + rwhisker = values[vlen - 1]; + } + } + this.quartiles = [q1, q2, q3]; + this.lwhisker = lwhisker; + this.rwhisker = rwhisker; + this.loutlier = loutlier; + this.routlier = routlier; + + unitSize = canvasWidth / (maxValue - minValue + 1); + if (options.get('showOutliers')) { + canvasLeft = Math.ceil(options.get('spotRadius')); + canvasWidth -= 2 * Math.ceil(options.get('spotRadius')); + unitSize = canvasWidth / (maValue - minValue + 1); + if (loutlier < lwhisker) { + target.drawCircle((loutlier - minValue) * unitSize + canvasLeft, + canvasHeight / 2, + options.get('spotRadius'), + options.get('outlierLineColor'), + options.get('outlierFillColor')).append(); + } + if (routlier > rwhisker) { + target.drawCircle((routlier - minValue) * unitSize + canvasLeft, + canvasHeight / 2, + options.get('spotRadius'), + options.get('outlierLineColor'), + options.get('outlierFillColor')).append(); + } + } + + // box + target.drawRect( + Math.round((q1 - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight * 0.1), + Math.round((q3 - q1) * unitSize), + Math.round(canvasHeight * 0.8), + options.get('boxLineColor'), + options.get('boxFillColor')).append(); + // left whisker + target.drawLine( + Math.round((lwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 2), + Math.round((q1 - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 2), + options.get('lineColor')).append(); + target.drawLine( + Math.round((lwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 4), + Math.round((lwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight - canvasHeight / 4), + options.get('whiskerColor')).append(); + // right whisker + target.drawLine(Math.round((rwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 2), + Math.round((q3 - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 2), + options.get('lineColor')).append(); + target.drawLine( + Math.round((rwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight / 4), + Math.round((rwhisker - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight - canvasHeight / 4), + options.get('whiskerColor')).append(); + // median line + target.drawLine( + Math.round((q2 - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight * 0.1), + Math.round((q2 - minValue) * unitSize + canvasLeft), + Math.round(canvasHeight * 0.9), + options.get('medianColor')).append(); + if (options.get('target')) { + size = Math.ceil(options.get('spotRadius')); + target.drawLine( + Math.round((options.get('target') - minValue) * unitSize + canvasLeft), + Math.round((canvasHeight / 2) - size), + Math.round((options.get('target') - minValue) * unitSize + canvasLeft), + Math.round((canvasHeight / 2) + size), + options.get('targetColor')).append(); + target.drawLine( + Math.round((options.get('target') - minValue) * unitSize + canvasLeft - size), + Math.round(canvasHeight / 2), + Math.round((options.get('target') - minValue) * unitSize + canvasLeft + size), + Math.round(canvasHeight / 2), + options.get('targetColor')).append(); + } + target.render(); + } + }); + + // Setup a very simple "virtual canvas" to make drawing the few shapes we need easier + // This is accessible as $(foo).simpledraw() + + if ($.browser.msie && document.namespaces && !document.namespaces.v) { + document.namespaces.add('v', 'urn:schemas-microsoft-com:vml', '#default#VML'); + } + + if ($.browser.hasCanvas === undefined) { + $.browser.hasCanvas = document.createElement('canvas').getContext !== undefined; + } + + VShape = createClass({ + init: function (target, id, type, args) { + this.target = target; + this.id = id; + this.type = type; + this.args = args; + }, + append: function () { + this.target.appendShape(this); + return this; + } + }); + + VCanvas_base = createClass({ + _pxregex: /(\d+)(px)?\s*$/i, + + init: function (width, height, target) { + if (!width) { + return; + } + this.width = width; + this.height = height; + this.target = target; + this.lastShapeId = null; + if (target[0]) { + target = target[0]; + } + $.data(target, '_jqs_vcanvas', this); + }, + + drawLine: function (x1, y1, x2, y2, lineColor, lineWidth) { + return this.drawShape([[x1, y1], [x2, y2]], lineColor, lineWidth); + }, + + drawShape: function (path, lineColor, fillColor, lineWidth) { + return this._genShape('Shape', [path, lineColor, fillColor, lineWidth]); + }, + + drawCircle: function (x, y, radius, lineColor, fillColor, lineWidth) { + return this._genShape('Circle', [x, y, radius, lineColor, fillColor, lineWidth]); + }, + + drawPieSlice: function (x, y, radius, startAngle, endAngle, lineColor, fillColor) { + return this._genShape('PieSlice', [x, y, radius, startAngle, endAngle, lineColor, fillColor]); + }, + + drawRect: function (x, y, width, height, lineColor, fillColor) { + return this._genShape('Rect', [x, y, width, height, lineColor, fillColor]); + }, + + getElement: function () { + return this.canvas; + }, + + /** + * Return the most recently inserted shape id + */ + getLastShapeId: function () { + return this.lastShapeId; + }, + + /** + * Clear and reset the canvas + */ + reset: function () { + alert('reset not implemented'); + }, + + _insert: function (el, target) { + $(target).html(el); + }, + + /** + * Calculate the pixel dimensions of the canvas + */ + _calculatePixelDims: function (width, height, canvas) { + // XXX This should probably be a configurable option + var match; + match = this._pxregex.exec(height); + if (match) { + this.pixelHeight = match[1]; + } else { + this.pixelHeight = $(canvas).height(); + } + match = this._pxregex.exec(width); + if (match) { + this.pixelWidth = match[1]; + } else { + this.pixelWidth = $(canvas).width(); + } + }, + + /** + * Generate a shape object and id for later rendering + */ + _genShape: function (shapetype, shapeargs) { + var id = shapeCount++; + shapeargs.unshift(id); + return new VShape(this, id, shapetype, shapeargs); + }, + + /** + * Add a shape to the end of the render queue + */ + appendShape: function (shape) { + alert('appendShape not implemented'); + }, + + /** + * Replace one shape with another + */ + replaceWithShape: function (shapeid, shape) { + alert('replaceWithShape not implemented'); + }, + + /** + * Insert one shape after another in the render queue + */ + insertAfterShape: function (shapeid, shape) { + alert('insertAfterShape not implemented'); + }, + + /** + * Remove a shape from the queue + */ + removeShapeId: function (shapeid) { + alert('removeShapeId not implemented'); + }, + + /** + * Find a shape at the specified x/y co-ordinates + */ + getShapeAt: function (el, x, y) { + alert('getShapeAt not implemented'); + }, + + /** + * Render all queued shapes onto the canvas + */ + render: function () { + alert('render not implemented'); + } + }); + + VCanvas_canvas = createClass(VCanvas_base, { + init: function (width, height, target, interact) { + VCanvas_canvas._super.init.call(this, width, height, target); + this.canvas = document.createElement('canvas'); + if (target[0]) { + target = target[0]; + } + $.data(target, '_jqs_vcanvas', this); + $(this.canvas).css({ display: 'inline-block', width: width, height: height, verticalAlign: 'top' }); + this._insert(this.canvas, target); + this._calculatePixelDims(width, height, this.canvas); + this.canvas.width = this.pixelWidth; + this.canvas.height = this.pixelHeight; + this.interact = interact; + this.shapes = {}; + this.shapeseq = []; + this.currentTargetShapeId = undefined; + $(this.canvas).css({width: this.pixelWidth, height: this.pixelHeight}); + }, + + _getContext: function (lineColor, fillColor, lineWidth) { + var context = this.canvas.getContext('2d'); + if (lineColor !== undefined) { + context.strokeStyle = lineColor; + } + context.lineWidth = lineWidth === undefined ? 1 : lineWidth; + if (fillColor !== undefined) { + context.fillStyle = fillColor; + } + return context; + }, + + reset: function () { + var context = this._getContext(); + context.clearRect(0, 0, this.pixelWidth, this.pixelHeight); + this.shapes = {}; + this.shapeseq = []; + this.currentTargetShapeId = undefined; + }, + + _drawShape: function (shapeid, path, lineColor, fillColor, lineWidth) { + var context = this._getContext(lineColor, fillColor, lineWidth), + i, plen; + context.beginPath(); + context.moveTo(path[0][0] + 0.5, path[0][1] + 0.5); + for (i = 1, plen = path.length; i < plen; i++) { + context.lineTo(path[i][0] + 0.5, path[i][1] + 0.5); // the 0.5 offset gives us crisp pixel-width lines + } + if (lineColor !== undefined) { + context.stroke(); + } + if (fillColor !== undefined) { + context.fill(); + } + if (this.targetX !== undefined && this.targetY !== undefined && + context.isPointInPath(this.targetX, this.targetY)) { + this.currentTargetShapeId = shapeid; + } + }, + + _drawCircle: function (shapeid, x, y, radius, lineColor, fillColor, lineWidth) { + var context = this._getContext(lineColor, fillColor, lineWidth); + context.beginPath(); + context.arc(x, y, radius, 0, 2 * Math.PI, false); + if (this.targetX !== undefined && this.targetY !== undefined && + context.isPointInPath(this.targetX, this.targetY)) { + this.currentTargetShapeId = shapeid; + } + if (lineColor !== undefined) { + context.stroke(); + } + if (fillColor !== undefined) { + context.fill(); + } + }, + + _drawPieSlice: function (shapeid, x, y, radius, startAngle, endAngle, lineColor, fillColor) { + var context = this._getContext(lineColor, fillColor); + context.beginPath(); + context.moveTo(x, y); + context.arc(x, y, radius, startAngle, endAngle, false); + context.lineTo(x, y); + context.closePath(); + if (lineColor !== undefined) { + context.stroke(); + } + if (fillColor) { + context.fill(); + } + if (this.targetX !== undefined && this.targetY !== undefined && + context.isPointInPath(this.targetX, this.targetY)) { + this.currentTargetShapeId = shapeid; + } + }, + + _drawRect: function (shapeid, x, y, width, height, lineColor, fillColor) { + return this._drawShape(shapeid, [[x, y], [x + width, y], [x + width, y + height], [x, y + height], [x, y]], lineColor, fillColor); + }, + + appendShape: function (shape) { + this.shapes[shape.id] = shape; + this.shapeseq.push(shape.id); + this.lastShapeId = shape.id; + return shape.id; + }, + + replaceWithShape: function (shapeid, shape) { + var shapeseq = this.shapeseq, + i; + this.shapes[shape.id] = shape; + for (i = shapeseq.length; i--;) { + if (shapeseq[i] == shapeid) { + shapeseq[i] = shape.id; + } + } + delete this.shapes[shapeid]; + }, + + replaceWithShapes: function (shapeids, shapes) { + var shapeseq = this.shapeseq, + shapemap = {}, + sid, i, first; + + for (i = shapeids.length; i--;) { + shapemap[shapeids[i]] = true; + } + for (i = shapeseq.length; i--;) { + sid = shapeseq[i]; + if (shapemap[sid]) { + shapeseq.splice(i, 1); + delete this.shapes[sid]; + first = i; + } + } + for (i = shapes.length; i--;) { + shapeseq.splice(first, 0, shapes[i].id); + this.shapes[shapes[i].id] = shapes[i]; + } + + }, + + insertAfterShape: function (shapeid, shape) { + var shapeseq = this.shapeseq, + i; + for (i = shapeseq.length; i--;) { + if (shapeseq[i] === shapeid) { + shapeseq.splice(i + 1, 0, shape.id); + this.shapes[shape.id] = shape; + return; + } + } + }, + + removeShapeId: function (shapeid) { + var shapeseq = this.shapeseq, + i; + for (i = shapeseq.length; i--;) { + if (shapeseq[i] === shapeid) { + shapeseq.splice(i, 1); + break; + } + } + delete this.shapes[shapeid]; + }, + + getShapeAt: function (el, x, y) { + this.targetX = x; + this.targetY = y; + this.render(); + return this.currentTargetShapeId; + }, + + render: function () { + var shapeseq = this.shapeseq, + shapes = this.shapes, + shapeCount = shapeseq.length, + context = this._getContext(), + shapeid, shape, i; + context.clearRect(0, 0, this.pixelWidth, this.pixelHeight); + for (i = 0; i < shapeCount; i++) { + shapeid = shapeseq[i]; + shape = shapes[shapeid]; + this['_draw' + shape.type].apply(this, shape.args); + } + if (!this.interact) { + // not interactive so no need to keep the shapes array + this.shapes = {}; + this.shapeseq = []; + } + } + + }); + + VCanvas_vml = createClass(VCanvas_base, { + init: function (width, height, target) { + var groupel; + VCanvas_vml._super.init.call(this, width, height, target); + if (target[0]) { + target = target[0]; + } + $.data(target, '_jqs_vcanvas', this); + this.canvas = document.createElement('span'); + $(this.canvas).css({ display: 'inline-block', position: 'relative', overflow: 'hidden', width: width, height: height, margin: '0px', padding: '0px', verticalAlign: 'top'}); + this._insert(this.canvas, target); + this._calculatePixelDims(width, height, this.canvas); + this.canvas.width = this.pixelWidth; + this.canvas.height = this.pixelHeight; + groupel = ''; + this.canvas.insertAdjacentHTML('beforeEnd', groupel); + this.group = $(this.canvas).children()[0]; + this.rendered = false; + this.prerender = ''; + }, + + _drawShape: function (shapeid, path, lineColor, fillColor, lineWidth) { + var vpath = [], + initial, stroke, fill, closed, vel, plen, i; + for (i = 0, plen = path.length; i < plen; i++) { + vpath[i] = '' + (path[i][0]) + ',' + (path[i][1]); + } + initial = vpath.splice(0, 1); + lineWidth = lineWidth === undefined ? 1 : lineWidth; + stroke = lineColor === undefined ? ' stroked="false" ' : ' strokeWeight="' + lineWidth + 'px" strokeColor="' + lineColor + '" '; + fill = fillColor === undefined ? ' filled="false"' : ' fillColor="' + fillColor + '" filled="true" '; + closed = vpath[0] === vpath[vpath.length - 1] ? 'x ' : ''; + vel = '' + + ' '; + return vel; + }, + + _drawCircle: function (shapeid, x, y, radius, lineColor, fillColor, lineWidth) { + var stroke, fill, vel; + x -= radius; + y -= radius; + stroke = lineColor === undefined ? ' stroked="false" ' : ' strokeWeight="' + lineWidth + 'px" strokeColor="' + lineColor + '" '; + fill = fillColor === undefined ? ' filled="false"' : ' fillColor="' + fillColor + '" filled="true" '; + vel = ''; + return vel; + + }, + + _drawPieSlice: function (shapeid, x, y, radius, startAngle, endAngle, lineColor, fillColor) { + var vpath, startx, starty, endx, endy, stroke, fill, vel; + if (startAngle === endAngle) { + return; // VML seems to have problem when start angle equals end angle. + } + if ((endAngle - startAngle) === (2 * Math.PI)) { + startAngle = 0.0; // VML seems to have a problem when drawing a full circle that doesn't start 0 + endAngle = (2 * Math.PI); + } + + startx = x + Math.round(Math.cos(startAngle) * radius); + starty = y + Math.round(Math.sin(startAngle) * radius); + endx = x + Math.round(Math.cos(endAngle) * radius); + endy = y + Math.round(Math.sin(endAngle) * radius); + + // Prevent very small slices from being mistaken as a whole pie + if (startx === endx && starty === endy && (endAngle - startAngle) < Math.PI) { + return; + } + + vpath = [x - radius, y - radius, x + radius, y + radius, startx, starty, endx, endy]; + stroke = lineColor === undefined ? ' stroked="false" ' : ' strokeWeight="1px" strokeColor="' + lineColor + '" '; + fill = fillColor === undefined ? ' filled="false"' : ' fillColor="' + fillColor + '" filled="true" '; + vel = '' + + ' '; + return vel; + }, + + _drawRect: function (shapeid, x, y, width, height, lineColor, fillColor) { + return this._drawShape(shapeid, [[x, y], [x, y + height], [x + width, y + height], [x + width, y], [x, y]], lineColor, fillColor); + }, + + reset: function () { + this.group.innerHTML = ''; + }, + + appendShape: function (shape) { + var vel = this['_draw' + shape.type].apply(this, shape.args); + if (this.rendered) { + this.group.insertAdjacentHTML('beforeEnd', vel); + } else { + this.prerender += vel; + } + this.lastShapeId = shape.id; + return shape.id; + }, + + replaceWithShape: function (shapeid, shape) { + var existing = $('#jqsshape' + shapeid), + vel = this['_draw' + shape.type].apply(this, shape.args); + existing[0].outerHTML = vel; + }, + + replaceWithShapes: function (shapeids, shapes) { + // replace the first shapeid with all the new shapes then toast the remaining old shapes + var existing = $('#jqsshape' + shapeids[0]), + replace = '', + slen = shapes.length, + i; + for (i = 0; i < slen; i++) { + replace += this['_draw' + shapes[i].type].apply(this, shapes[i].args); + } + existing[0].outerHTML = replace; + for (i = 1; i < shapeids.length; i++) { + $('#jqsshape' + shapeids[i]).remove(); + } + }, + + insertAfterShape: function (shapeid, shape) { + var existing = $('#jqsshape' + shapeid), + vel = this['_draw' + shape.type].apply(this, shape.args); + existing[0].insertAdjacentHTML('afterEnd', vel); + }, + + removeShapeId: function (shapeid) { + var existing = $('#jqsshape' + shapeid); + this.group.removeChild(existing[0]); + }, + + getShapeAt: function (el, x, y) { + var shapeid = el.id.substr(8); + return shapeid; + }, + + render: function () { + if (!this.rendered) { + // batch the intial render into a single repaint + this.group.innerHTML = this.prerender; + this.rendered = true; + } + } + }); + +})); + --- a/ckanext/ga_report/templates/ga_report/ga_popular_datasets.html +++ b/ckanext/ga_report/templates/ga_report/ga_popular_datasets.html @@ -5,7 +5,7 @@ --- /dev/null +++ b/ckanext/ga_report/templates/ga_report/site/downloads.html @@ -1,1 +1,59 @@ + + + + Downloads + + +
  • +

    Download

    +

    + Download as CSV
    +

    +
  • + + +
    + +
    +

    Downloads

    + ${usage_nav('Downloads')} + +
    +
    + + ${month_selector(c.month, c.months, c.day)} + + +
    +
    + + + ${downloads_table(c.downloads)} + + +

    No data

    +

    There is no download data available for this month

    +
    +
    + + + + + + + + + + + --- a/ckanext/ga_report/templates/ga_report/site/index.html +++ b/ckanext/ga_report/templates/ga_report/site/index.html @@ -7,36 +7,38 @@ Site usage + + + + + -
  • -

    Site-wide

    -

    - Note: this data does not include API calls and some values have been rounded up to 2 decimal places. Where there are a large number of browser versions they have been grouped together. -

    -
  • Download

    - Download as CSV
    + Download as CSV

  • +

    Site Usage

    - ${usage_nav('Site-wide', None)} + ${usage_nav('Site-wide')}
    - - + ${month_selector(c.month, c.months, c.day)} + +
    @@ -78,16 +80,23 @@ Name Value + History - + ${name} ${value} + + + ${','.join([y for x,y in graph])} + +
    +

    Note: Where a browser has a large number of versions, these have been grouped together.

    ${stat_table(c.browser_versions)}
    @@ -100,11 +109,11 @@ ${stat_table(c.os_versions)}
    -

    Number of visits to urls referred from social networks

    +

    Number of visits that were referred from social networks

    ${social_table(c.social_referrer_totals)}
    -

    Percentage of visits referred from these social networks

    +

    Percentage of visits that were referred from these social networks

    ${stat_table(c.social_networks, 'Visits')}
    @@ -123,18 +132,29 @@
    - - + + --- /dev/null +++ b/ckanext/ga_report/tests/test_download.py @@ -1,1 +1,30 @@ +from nose.tools import assert_equal +from ckanext.ga_report.download_analytics import DownloadAnalytics + +_filter_browser_version = DownloadAnalytics._filter_browser_version + +class TestBrowserVersionFilter: + def test_chrome(self): + assert_equal(_filter_browser_version('Chrome', u'6.0.472.0'), '6') + def test_firefox(self): + assert_equal(_filter_browser_version('Firefox', u'16.1'), '16') + def test_safari(self): + assert_equal(_filter_browser_version('Safari', u'534.55.3'), '53X') + assert_equal(_filter_browser_version('Safari', u'1534.55.3'), '15XX') + def test_ie(self): + assert_equal(_filter_browser_version('Internet Explorer', u'8.0'), '8') + def test_opera_mini(self): + assert_equal(_filter_browser_version('Opera Mini', u'6.5.27431'), '6') + def test_opera(self): + assert_equal(_filter_browser_version('Opera', u'11.60'), '11') + +class TestDownloadAnalytics: + def test_filter_out_long_tail(self): + data = {'Firefox': 100, + 'Obscure Browser': 5, + 'Chrome': 150} + DownloadAnalytics._filter_out_long_tail(data, 10) + assert_equal(data, {'Firefox': 100, + 'Chrome': 150}) + --- /dev/null +++ b/ckanext/ga_report/tests/test_model.py @@ -1,1 +1,18 @@ +from nose.tools import assert_equal +from ckanext.ga_report.ga_model import _normalize_url + +class TestNormalizeUrl: + def test_normal(self): + assert_equal(_normalize_url('http://data.gov.uk/dataset/weekly_fuel_prices'), + '/dataset/weekly_fuel_prices') + + def test_www_dot(self): + assert_equal(_normalize_url('http://www.data.gov.uk/dataset/weekly_fuel_prices'), + '/dataset/weekly_fuel_prices') + + def test_https(self): + assert_equal(_normalize_url('https://data.gov.uk/dataset/weekly_fuel_prices'), + '/dataset/weekly_fuel_prices') + + --- a/setup.py +++ b/setup.py @@ -26,13 +26,14 @@ entry_points=\ """ [ckan.plugins] - # Add plugins here, eg + # Add plugins here ga-report=ckanext.ga_report.plugin:GAReportPlugin [paste.paster_command] loadanalytics = ckanext.ga_report.command:LoadAnalytics initdb = ckanext.ga_report.command:InitDB getauthtoken = ckanext.ga_report.command:GetAuthToken + fixtimeperiods = ckanext.ga_report.command:FixTimePeriods """, )