diff --git a/r2/Makefile b/r2/Makefile index d1a411672..e5956f19d 100644 --- a/r2/Makefile +++ b/r2/Makefile @@ -61,19 +61,19 @@ clean_pyx: #################### i18n STRINGS_FILE := r2/lib/strings.py -RAND_STRINGS_FILE := r2/lib/rand_strings.py +GENERATED_STRINGS_FILE := r2/lib/generated_strings.py POTFILE := $(I18NPATH)/r2.pot .PHONY: i18n clean_i18n -i18n: $(RAND_STRINGS_FILE) +i18n: $(GENERATED_STRINGS_FILE) $(PYTHON) setup.py extract_messages -o $(POTFILE) -$(RAND_STRINGS_FILE): $(STRINGS_FILE) - paster run standalone $(STRINGS_FILE) -c "print_rand_strings()" > $(RAND_STRINGS_FILE) +$(GENERATED_STRINGS_FILE): $(STRINGS_FILE) + paster run standalone $(STRINGS_FILE) -c "generate_strings()" > $(GENERATED_STRINGS_FILE) clean_i18n: - rm -f $(RAND_STRINGS_FILE) + rm -f $(GENERATED_STRINGS_FILE) #################### ini files UPDATE_FILES := $(wildcard *.update) diff --git a/r2/r2/config/routing.py b/r2/r2/config/routing.py index 2b512adac..b2da704c8 100644 --- a/r2/r2/config/routing.py +++ b/r2/r2/config/routing.py @@ -53,6 +53,8 @@ def make_map(): mc('/rules', controller='front', action='rules') mc('/sup', controller='front', action='sup') mc('/traffic', controller='front', action='site_traffic') + mc('/traffic/languages/:langcode', controller='front', action='lang_traffic', langcode='') + mc('/traffic/adverts/:code', controller='front', action='advert_traffic', code='') mc('/account-activity', controller='front', action='account_activity') mc('/about/message/:where', controller='message', action='listing') diff --git a/r2/r2/controllers/front.py b/r2/r2/controllers/front.py index 17daf1c2a..6db0b93c3 100755 --- a/r2/r2/controllers/front.py +++ b/r2/r2/controllers/front.py @@ -28,6 +28,7 @@ from r2.models import * from r2.config.extensions import is_api from r2.lib.pages import * from r2.lib.pages.things import wrap_links +from r2.lib.pages import trafficpages from r2.lib.menus import * from r2.lib.utils import to36, sanitize_url, check_cheating, title_to_url from r2.lib.utils import query_string, UrlParser, link_from_url, link_duplicates @@ -584,8 +585,8 @@ class FrontController(RedditController): pane = self._make_spamlisting(location, num, after, reverse, count) if c.user.pref_private_feeds: extension_handling = "private" - elif is_moderator and location == 'traffic': - pane = RedditTraffic() + elif (is_moderator or c.user_is_sponsor) and location == 'traffic': + pane = trafficpages.SubredditTraffic() elif is_moderator and location == 'flair': c.allow_styles = True pane = FlairPane(num, after, reverse, name, user) @@ -923,19 +924,27 @@ class FrontController(RedditController): @validate(VTrafficViewer('article'), article = VLink('article')) def GET_traffic(self, article): - content = PromotedTraffic(article) + content = trafficpages.PromotedLinkTraffic(article) if c.render_style == 'csv': c.response.content = content.as_csv() return c.response - return LinkInfoPage(link = article, - comment = None, - content = content).render() + return LinkInfoPage(link=article, + page_classes=["promoted-traffic"], + comment=None, + content=content).render() @validate(VSponsorAdmin()) def GET_site_traffic(self): - return BoringPage("traffic", - content = RedditTraffic()).render() + return trafficpages.SitewideTrafficPage().render() + + @validate(VSponsorAdmin()) + def GET_lang_traffic(self, langcode): + return trafficpages.LanguageTrafficPage(langcode).render() + + @validate(VSponsorAdmin()) + def GET_advert_traffic(self, code): + return trafficpages.AdvertTrafficPage(code).render() @validate(VUser()) def GET_account_activity(self): diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index cd0865890..d31085833 100755 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -300,8 +300,19 @@ module["sponsored"] = Module("sponsored.js", "sponsored.js" ) -module["flot"] = Module("jquery.flot.js", - "lib/jquery.flot.js" +module["timeseries"] = Module("timeseries.js", + "lib/jquery.flot.js", + "lib/jquery.flot.time.js", + "timeseries.js", +) + +module["timeseries-ie"] = Module("timeseries-ie.js", + "lib/excanvas.min.js", + module["timeseries"], +) + +module["traffic"] = LocalizedModule("traffic.js", + "traffic.js", ) def use(*names): diff --git a/r2/r2/lib/menus.py b/r2/r2/lib/menus.py index e2020bbeb..3853d3287 100644 --- a/r2/r2/lib/menus.py +++ b/r2/r2/lib/menus.py @@ -173,6 +173,10 @@ menu = MenuHandler(hot = _('hot'), pending_promos = _('pending'), rejected_promos = _('rejected'), + sitewide = _('sitewide'), + languages = _('languages'), + adverts = _('adverts'), + whitelist = _("whitelist") ) diff --git a/r2/r2/lib/pages/graph.py b/r2/r2/lib/pages/graph.py deleted file mode 100644 index c1b522fa2..000000000 --- a/r2/r2/lib/pages/graph.py +++ /dev/null @@ -1,232 +0,0 @@ -# The contents of this file are subject to the Common Public Attribution -# License Version 1.0. (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public -# License Version 1.1, but Sections 14 and 15 have been added to cover use of -# software over a computer network and provide for limited attribution for the -# Original Developer. In addition, Exhibit A has been modified to be consistent -# with Exhibit B. -# -# Software distributed under the License is distributed on an "AS IS" basis, -# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for -# the specific language governing rights and limitations under the License. -# -# The Original Code is reddit. -# -# The Original Developer is the Initial Developer. The Initial Developer of -# the Original Code is reddit Inc. -# -# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit -# Inc. All Rights Reserved. -############################################################################### - -import math, datetime - -def google_extended(n): - """Computes the google extended encoding of an int in [0, 4096)""" - numerals = ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789-.") - base = len(numerals) - assert(0 <= n <= base ** 2) - q, r = divmod(int(n), base) - return numerals[q] + numerals[r] - -def make_date_axis_labels(series): - """ - assuming a uniform date series, generate a suitable axis label - """ - _max = max(series) - _min = min(series) - delta = _max - _min - zero = datetime.timedelta(0) - has_hour = isinstance(_min, datetime.datetime) - if delta != zero and has_hour and delta < datetime.timedelta(0, 0.5*86400): - test = lambda cur, prev: cur.hour != prev.hour and cur.hour % 3 == 0 - format = "%H:00" - elif delta != zero and has_hour and delta < datetime.timedelta(2): - test = lambda cur, prev: cur.hour != prev.hour and cur.hour % 6 == 0 - format = "%H:00" - elif delta == zero or delta < datetime.timedelta(7): - test = lambda cur, prev: cur.day != prev.day - format = "%d %b" - elif delta < datetime.timedelta(14): - test = lambda cur, prev: cur.day != prev.day and cur.day % 2 == 0 - format = "%d %b" - elif delta < datetime.timedelta(30): - test = lambda cur, prev: (cur.day != prev.day) and cur.weekday() == 6 - format = "%d %b" - else: - test = lambda cur, prev: (cur.month != prev.month) - format = "%b" - new_series = [] - prev = None - for s in series: - if prev and test(s, prev): - new_series.append(s.strftime(format)) - else: - new_series.append("") - prev = s - return new_series - - -class DataSeries(list): - def __init__(self, data): - list.__init__(self, data) - - def low_precision_max(self, precision = 2): - """ - Compute the max of the data set, including at most 'precision' - units of decimal precision in the result (e.g., 9893 -> 9900 if - precision = 2) - """ - _max = float(max(self)) - if _max == 0: - return 0 - scale = math.log10(_max) - scale = 10 ** (math.ceil(scale) - precision) - return math.ceil(_max / scale) * scale - - def normalize(self, norm_max = 100, precision = 2, _max = None): - _min = min(self) - _max = _max or max(self) - if _min == _max: - return DataSeries(int(norm_max)/2. - for i in xrange(len(self))) - else: - return DataSeries(min(int(x * float(norm_max) / _max), norm_max -1) - for x in self) - - def toBarY(self): - data = [] - for i in xrange(len(self)): - data += [self[i], self[i]] - return DataSeries(data) - - def toBarX(self): - if len(self) > 1: - delta = self[-1] - self[-2] - else: - delta = 0 - data = self.toBarY() - return DataSeries(data[1:] + [data[-1] + delta]) - - def is_regular(self): - return all(self[i] - self[i-1] == self[1] - self[0] - for i in xrange(1, len(self))) - - def to_google_extended(self, precision = 1, _max = None): - if _max is None: - _max = self.low_precision_max(precision = precision) - norm_max = 4096 - new = self.normalize(norm_max = norm_max, precision = precision, - _max = _max) - return _max, "".join(map(google_extended, new)) - -class LineGraph(object): - """ - General line chart class for plotting xy line graphs. - - data is passed in as a series of tuples of the form (x, y_1, ..., - y_n) and converted to a single xdata DataSeries, and a list of - ydata DataSeries elements. The intention of this class is to be - able to handle multiple final plot representations, thought - currenly only google charts is available. - - At some point, it also might make sense to connect this more - closely with numpy. - - """ - google_api = "http://chart.apis.google.com/chart" - - def __init__(self, xydata, colors = ("FF4500", "336699"), - width = 300, height = 175): - - series = zip(*xydata) - - self.xdata = DataSeries(series[0]) - self.ydata = map(DataSeries, series[1:]) - self.width = width - self.height = height - self.colors = colors - - def google_chart(self, multiy = True, ylabels = [], title = "", - bar_fmt = True): - xdata, ydata = self.xdata, self.ydata - - # Bar format makes the line chart look like it is a series of - # contiguous bars without the boundary line between each bar. - if bar_fmt: - xdata = DataSeries(range(len(self.xdata))).toBarX() - ydata = [y.toBarY() for y in self.ydata] - - # TODO: currently we are only supporting time series. Make general - xaxis = make_date_axis_labels(self.xdata) - - # Convert x data into google extended text format - xmax, xdata = xdata.to_google_extended(_max = max(xdata)) - ymax0 = None - - # multiy <=> 2 y axes with independent scaling. not multiy - # means we need to know what the global max is over all y data - multiy = multiy and len(ydata) == 2 - if not multiy: - ymax0 = max(y.low_precision_max() for y in ydata) - - def make_labels(i, m, p = 4): - return (("%d:|" % i) + - '|'.join(str(i * m / p) - for i in range(p+1))) - - # data stores a list of xy data strings in google's format - data = [] - labels = [] - for i in range(len(ydata)): - ymax, y = ydata[i].to_google_extended(_max = ymax0) - data.append(xdata + ',' + y) - if multiy: - labels.append(make_labels(i,ymax)) - if not multiy: - labels.append(make_labels(0,ymax0)) - - if multiy: - labels.append('2:|' + '|'.join(xaxis)) - axes = 'y,r,x' - if len(self.colors) > 1: - ycolor = "0,%s|1,%s" % (self.colors[0], self.colors[1]) - else: - ycolor = "" - else: - labels.append('1:|' + '|'.join(xaxis)) - axes = 'y,x' - ycolor="", - if ylabels: - axes += ',t' - labels.append('%d:|' % (len(axes)/2) + - ('|' if multiy else ', ').join(ylabels)) - if title: - axes += ',t' - labels.append('%d:||%s|' % (len(axes)/2, title)) - - if len(self.colors) >= len(self.ydata): - colors = ",".join(self.colors[:len(self.ydata)]) - else: - colors = "" - args = dict(# chart type is xy - cht = 'lxy', - # chart size - chs = "%sx%s" % (self.width, self.height), - # which axes are labeled - chxt= axes, - # axis labels - chxl = '|'.join(labels), - # chart data is in extended format - chd = 'e:' + ','.join(data), - chco = colors, - chxs = ycolor - ) - - return (self.google_api + - '?' + '&'.join('%s=%s' % (k, v) for k, v in args.iteritems())) - - diff --git a/r2/r2/lib/pages/pages.py b/r2/r2/lib/pages/pages.py index 9a766dd1c..ae2d09754 100755 --- a/r2/r2/lib/pages/pages.py +++ b/r2/r2/lib/pages/pages.py @@ -28,6 +28,7 @@ from r2.models import Link, Printable, Trophy, bidding, PromotionWeights, Commen from r2.models import Flair, FlairTemplate, FlairTemplateBySubredditIndex from r2.models import USER_FLAIR, LINK_FLAIR from r2.models.oauth2 import OAuth2Client +from r2.models import traffic from r2.models import ModAction from r2.models import Thing from r2.config import cache @@ -40,7 +41,6 @@ from pylons import c, request, g from pylons.controllers.util import abort from r2.lib import promote -from r2.lib.traffic import load_traffic, load_summary from r2.lib.captcha import get_iden from r2.lib.filters import spaceCompress, _force_unicode, _force_utf8 from r2.lib.filters import unsafe, websafe, SC_ON, SC_OFF, websafe_json @@ -49,7 +49,7 @@ from r2.lib.menus import SubredditButton, SubredditMenu, ModeratorMailButton from r2.lib.menus import OffsiteButton, menu, JsNavMenu from r2.lib.strings import plurals, rand_strings, strings, Score from r2.lib.utils import title_to_url, query_string, UrlParser, to_js, vote_hash -from r2.lib.utils import link_duplicates, make_offset_date, to_csv, median, to36 +from r2.lib.utils import link_duplicates, make_offset_date, median, to36 from r2.lib.utils import trunc_time, timesince, timeuntil from r2.lib.template_helpers import add_sr, get_domain, format_number from r2.lib.subreddit_search import popular_searches @@ -60,7 +60,7 @@ from r2.lib.utils import trunc_string as _truncate from r2.lib.filters import safemarkdown import sys, random, datetime, calendar, simplejson, re, time -import graph, pycountry, time +import pycountry, time from itertools import chain from urllib import quote @@ -2896,32 +2896,6 @@ class BannedList(UserList): def user_ids(self): return c.site.banned -class TrafficViewerList(UserList): - """Traffic share list on /traffic/*""" - destination = "traffic_viewer" - remove_action = "rm_traffic_viewer" - type = 'traffic' - - def __init__(self, link, editable = True): - self.link = link - UserList.__init__(self, editable = editable) - - @property - def form_title(self): - return _('share traffic') - - @property - def table_title(self): - return _('current viewers') - - def user_ids(self): - return promote.traffic_viewers(self.link) - - @property - def container_name(self): - return self.link._fullname - - class DetailsPage(LinkInfoPage): extension_handling= False @@ -3042,7 +3016,8 @@ class PromoteLinkForm(Templated): self.now = promote.promo_datetime_now().date() start_date = promote.promo_datetime_now(offset = -14).date() end_date = promote.promo_datetime_now(offset = 14).date() - self.promo_traffic = dict(load_traffic('day', 'promos')) + + self.promo_traffic = dict(promote.traffic_totals()) self.market, self.promo_counter = \ Promote_Graph.get_market(None, start_date, end_date) @@ -3226,270 +3201,6 @@ class MediaEmbedBody(CachedTemplate): res = CachedTemplate.render(self, *a, **kw) return responsive(res, True) -class Traffic(Templated): - @staticmethod - def slice_traffic(traffic, *indices): - return [[a] + [b[i] for i in indices] for a, b in traffic] - - -class PromotedTraffic(Traffic): - """ - Traffic page for a promoted link, including 2 graphs (one for - impressions and one for clicks with uniques on each plotted in - multiy format) and a table of the data. - """ - def __init__(self, thing): - # TODO: needs a fix for multiple campaigns - self.thing = thing - d = until = None - self.traffic = [] - if thing.campaigns: - d = min(sd.date() if isinstance(sd, datetime.datetime) else sd - for sd, ed, bid, sr, trans_id in thing.campaigns.values() - if trans_id) - until = max(ed.date() if isinstance(ed, datetime.datetime) else ed - for sd, ed, bid, sr, trans_id in thing.campaigns.values() - if trans_id) - now = datetime.datetime.now(g.tz).date() - - # the results are preliminary until 1 day after the promotion ends - self.preliminary = (until + datetime.timedelta(1) > now) - self.traffic = load_traffic('hour', "thing", thing._fullname, - start_time = d, stop_time = until) - # TODO: ditch uniques and just sum the hourly values - self.totals = load_traffic('day', "thing", thing._fullname) - # generate a list of - # (uniq impressions, # impressions, uniq clicks, # clicks) - if self.totals: - self.totals = map(sum, zip(*zip(*self.totals)[1])) - - imp = self.slice_traffic(self.traffic, 0, 1) - - if len(imp) > 2: - imp_total = sum(x[2] for x in imp) - if self.totals: - self.totals[1] = max(self.totals[1], imp_total) - imp_total = format_number(imp_total) - self.imp_graph = TrafficGraph(imp[-72:], ylabels = ['uniques', 'total'], - title = ("recent impressions (%s total)" % - imp_total)) - cli = self.slice_traffic(self.traffic, 2, 3) - cli_total = sum(x[2] for x in cli) - if self.totals: - self.totals[3] = max(self.totals[3], cli_total) - cli_total = format_number(cli_total) - self.cli_graph = TrafficGraph(cli[-72:], ylabels = ['uniques', 'total'], - title = ("recent clicks (%s total)" % - cli_total)) - - else: - self.imp_graph = self.cli_graph = None - - editable = c.user_is_sponsor or c.user._id == thing.author_id - self.viewers = TrafficViewerList(thing, editable = editable) - Templated.__init__(self) - - def to_iter(self, localize = True, total = False): - locale = c.locale - def num(x): - if localize: - return format_number(x, locale) - return str(x) - def row(label, data): - uimp, nimp, ucli, ncli = data - return (label, - num(uimp), num(nimp), num(ucli), num(ncli), - ("%.2f%%" % (float(100*ucli) / uimp)) if uimp else "--.--%", - ("%.2f%%" % (float(100*ncli) / nimp)) if nimp else "--.--%") - - for date, data in self.traffic: - yield row(date.strftime("%Y-%m-%d %H:%M"), data) - if total: - yield row("total", self.totals) - - - def as_csv(self): - return to_csv(self.to_iter(localize = False, total = True)) - -class RedditTraffic(Traffic): - """ - fetches hourly and daily traffic for the current reddit. If the - current reddit is a default subreddit, fetches the site-wide - uniques and includes monthly totals. In this latter case, getter - methods are available for computing breakdown of site trafffic by - reddit. - """ - def __init__(self): - self.has_data = False - ivals = ["hour", "day", "month"] - - for ival in ivals: - if c.default_sr: - data = load_traffic(ival, "total", "") - else: - data = load_traffic(ival, "reddit", c.site.name) - if not data: - break - slices = [("uniques", (0, 2) if c.site.domain else (0,), - "FF4500"), - ("impressions", (1, 3) if c.site.domain else (1,), - "336699")] - if not c.default_sr and ival == 'day': - slices.append(("subscriptions", (4,), "00FF00")) - setattr(self, ival + "_data", data) - for name, indx, color in slices: - data2 = self.slice_traffic(data, *indx) - setattr(self, name + "_" + ival + "_chart", data2) - title = "%s by %s" % (name, ival) - res = TrafficGraph(data2, colors = [color], title = title) - setattr(self, name + "_" + ival, res) - else: - self.has_data = True - if self.has_data: - imp_by_day = [[] for i in range(7)] - uni_by_day = [[] for i in range(7)] - if c.site.domain: - dates, imps, foo = zip(*self.impressions_day_chart) - dates, uniques, foo = zip(*self.uniques_day_chart) - else: - dates, imps = zip(*self.impressions_day_chart) - dates, uniques = zip(*self.uniques_day_chart) - self.uniques_mean = sum(map(float, uniques))/len(uniques) - self.impressions_mean = sum(map(float, imps))/len(imps) - for i, d in enumerate(dates): - imp_by_day[d.weekday()].append(float(imps[i])) - uni_by_day[d.weekday()].append(float(uniques[i])) - self.uniques_by_dow = [sum(x)/max(len(x),1) - for x in uni_by_day] - self.impressions_by_dow = [sum(x)/max(len(x),1) - for x in imp_by_day] - Templated.__init__(self) - - def reddits_summary(self): - if c.default_sr: - data = map(list, load_summary("reddit")) - data.sort(key = lambda x: x[1][1], reverse = True) - for d in data: - name = d[0] - for sr in (Friends, All, Sub, DefaultSR()): - if name == sr.name: - name = sr - break - else: - try: - name = Subreddit._by_name(name) - except NotFound: - name = DomainSR(name) - d[0] = name - return data - return res - - def monthly_summary(self): - """ - Convenience method b/c it is bad form to do this much math - inside of a template.b - """ - res = [] - if c.default_sr: - data = self.month_data - - # figure out the mean number of users last month, - # unless today is the first and there is no data - days = self.day_data - now = datetime.datetime.utcnow() - if now.day != 1: - # project based on traffic so far - # totals are going to be up to yesterday - month_len = calendar.monthrange(now.year, now.month)[1] - - lastmonth = datetime.datetime.utcnow().month - lastmonthyear = datetime.datetime.utcnow().year - if lastmonth == 1: - lastmonthyear -= 1 - lastmonth = 1 - else: - lastmonth = (lastmonth - 1) if lastmonth != 1 else 12 - # length of last month - lastmonthlen = calendar.monthrange(lastmonthyear, lastmonth)[1] - - lastdays = filter(lambda x: x[0].month == lastmonth, days) - thisdays = filter(lambda x: x[0].month == now.month, days) - user_scale = 0 - if lastdays: - last_mean = (sum(u for (d, (u, v)) in lastdays) / - float(len(lastdays))) - day_mean = (sum(u for (d, (u, v)) in thisdays) / - float(len(thisdays))) - if last_mean and day_mean: - user_scale = ( (day_mean * month_len) / - (last_mean * lastmonthlen) ) - last_month_users = 0 - locale = c.locale - for x, (date, d) in enumerate(data): - res.append([("date", date.strftime("%Y-%m")), - ("", format_number(d[0], locale)), - ("", format_number(d[1], locale))]) - last_d = data[x-1][1] if x else None - for i in range(2): - # store last month's users for this month's projection - if x == len(data) - 2 and i == 0: - last_month_users = d[i] - if x == 0: - res[-1].append(("","")) - # project, unless today is the first of the month - elif x == len(data) - 1 and now.day != 1: - # yesterday - yday = (datetime.datetime.utcnow() - -datetime.timedelta(1)).day - if i == 0: - scaled = int(last_month_users * user_scale) - else: - scaled = float(d[i] * month_len) / yday - res[-1].append(("gray", - format_number(scaled, locale))) - elif last_d and d[i] and last_d[i]: - f = 100 * (float(d[i])/last_d[i] - 1) - - res[-1].append(("up" if f > 0 else "down", - "%5.2f%%" % f)) - return res - -class TrafficGraph(Templated): - def __init__(self, data, width = 300, height = 175, - bar_fmt = True, colors = ("FF4500", "336699"), title = '', - ylabels = [], multiy = True): - # fallback on google charts - chart = graph.LineGraph(data[:72], colors = colors) - self.gc = chart.google_chart(ylabels = ylabels, multiy = multiy, title = title) - - xdata = [] - ydata = [] - for d in data: - xdata.append(time.mktime(d[0].timetuple())*1000) - ydata.append(d[1:]) - ydata = zip(*ydata) - self.colors = colors - self.title = title - - if bar_fmt: - xdata = graph.DataSeries(xdata).toBarX() - - if ydata and not isinstance(ydata[0], (list, tuple)): - if bar_fmt: - ydata = graph.DataSeries(ydata).toBarY() - self.data = [zip(xdata, ydata)] - else: - self.data = [] - for ys in ydata: - if bar_fmt: - ys = graph.DataSeries(ys).toBarY() - self.data.append(zip(xdata, ys)) - - self.width = width - self.height = height - Templated.__init__(self) - - class RedditAds(Templated): def __init__(self, **kw): self.sr_name = c.site.name @@ -3574,6 +3285,10 @@ class Promotion_Summary(Templated): % ndays, p.render('email')) +def force_datetime(d): + return datetime.datetime.combine(d, datetime.time()) + + class Promote_Graph(Templated): @classmethod @@ -3665,84 +3380,43 @@ class Promote_Graph(Templated): else: break - # load recent traffic as well: - self.recent = {} - #TODO - for k, v in []:#load_summary("thing"): - if k.startswith('t%d_' % Link._type_id): - self.recent[k] = v - - if self.recent: - link_listing = wrap_links(self.recent.keys()) - for t in link_listing: - self.recent[t._fullname].insert(0, t) - - self.recent = self.recent.values() - self.recent.sort(key = lambda x: x[0]._date) - pool =PromotionWeights.bid_history(promote.promo_datetime_now(offset=-30), promote.promo_datetime_now(offset=2)) - if pool: - # we want to generate a stacked line graph, so store the - # bids and the total including refunded amounts - total_sale = sum((b-r) for (d, b, r) in pool) - total_refund = sum(r for (d, b, r) in pool) - - self.money_graph = TrafficGraph([(d, b, r) for (d, b, r) in pool], - colors = ("008800", "FF0000"), - ylabels = ['total ($)'], - title = ("monthly sales ($%.2f charged, $%.2f credits)" % - (total_sale, total_refund)), - multiy = False) - - #TODO - self.top_promoters = [] - else: - self.money_graph = None - self.top_promoters = [] # graphs of impressions and clicks - self.promo_traffic = load_traffic('day', 'promos') + self.promo_traffic = promote.traffic_totals() impressions = [(d, i) for (d, (i, k)) in self.promo_traffic] pool = dict((d, b+r) for (d, b, r) in pool) if impressions: - self.imp_graph = TrafficGraph(impressions, ylabels = ['total'], - title = "impressions") - - clicks = [(d, k) for (d, (i, k)) in self.promo_traffic] - - CPM = [(d, (pool.get(d, 0) * 1000. / i) if i else 0) + CPM = [(force_datetime(d), (pool.get(d, 0) * 1000. / i) if i else 0) for (d, (i, k)) in self.promo_traffic if d in pool] - - CPC = [(d, (100 * pool.get(d, 0) / k) if k else 0) - for (d, (i, k)) in self.promo_traffic if d in pool] - - CTR = [(d, (100 * float(k) / i if i else 0)) - for (d, (i, k)) in self.promo_traffic if d in pool] - - self.cli_graph = TrafficGraph(clicks, ylabels = ['total'], - title = "clicks") mean_CPM = sum(x[1] for x in CPM) * 1. / max(len(CPM), 1) - self.cpm_graph = TrafficGraph([(d, min(x, mean_CPM*2)) for d, x in CPM], - colors = ["336699"], ylabels = ['CPM ($)'], - title = "cost per 1k impressions " + - "($%.2f average)" % mean_CPM) + CPC = [(force_datetime(d), (100 * pool.get(d, 0) / k) if k else 0) + for (d, (i, k)) in self.promo_traffic if d in pool] mean_CPC = sum(x[1] for x in CPC) * 1. / max(len(CPC), 1) - self.cpc_graph = TrafficGraph([(d, min(x, mean_CPC*2)) for d, x in CPC], - colors = ["336699"], ylabels = ['CPC ($0.01)'], - title = "cost per click " + - "($%.2f average)" % (mean_CPC/100.)) - self.ctr_graph = TrafficGraph(CTR, colors = ["336699"], ylabels = ['CTR (%)'], - title = "click through rate") + cpm_title = _("cost per 1k impressions ($%(avg).2f average)") % dict(avg=mean_CPM) + cpc_title = _("cost per click ($%(avg).2f average)") % dict(avg=mean_CPC/100.) + data = traffic.zip_timeseries(((d, (min(v, mean_CPM * 2),)) for d, v in CPM), + ((d, (min(v, mean_CPC * 2),)) for d, v in CPC)) + + from r2.lib.pages.trafficpages import COLORS # not top level because of * imports :( + self.performance_table = TimeSeriesChart("promote-graph-table", + _("historical performance"), + "day", + [dict(color=COLORS.DOWNVOTE_BLUE, + title=cpm_title, + shortname=_("CPM")), + dict(color=COLORS.DOWNVOTE_BLUE, + title=cpc_title, + shortname=_("CPC"))], + data) else: - self.imp_graph = self.cli_graph = None - self.cpc_graph = self.cpm_graph = None - self.ctr_graph = None + self.performance_table = None self.promo_traffic = dict(self.promo_traffic) @@ -3767,9 +3441,6 @@ class Promote_Graph(Templated): "$%.2f" % link.promote_bid, _force_unicode(link.title)) - def as_csv(self): - return to_csv(self.to_iter(localize = False)) - class InnerToolbarFrame(Templated): def __init__(self, link, expanded = False): Templated.__init__(self, link = link, expanded = expanded) @@ -3904,3 +3575,17 @@ class ApiHelp(Templated): class RulesPage(Templated): pass + +class TimeSeriesChart(Templated): + def __init__(self, id, title, interval, columns, rows, + latest_available_data=None, classes=[]): + self.id = id + self.title = title + self.interval = interval + self.columns = columns + self.rows = rows + self.latest_available_data = (latest_available_data or + datetime.datetime.utcnow()) + self.classes = " ".join(classes) + + Templated.__init__(self) diff --git a/r2/r2/lib/pages/trafficpages.py b/r2/r2/lib/pages/trafficpages.py new file mode 100644 index 000000000..2764f182a --- /dev/null +++ b/r2/r2/lib/pages/trafficpages.py @@ -0,0 +1,430 @@ +# The contents of this file are subject to the Common Public Attribution +# License Version 1.0. (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +# License Version 1.1, but Sections 14 and 15 have been added to cover use of +# software over a computer network and provide for limited attribution for the +# Original Developer. In addition, Exhibit A has been modified to be consistent +# with Exhibit B. +# +# Software distributed under the License is distributed on an "AS IS" basis, +# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +# the specific language governing rights and limitations under the License. +# +# The Original Code is reddit. +# +# The Original Developer is the Initial Developer. The Initial Developer of +# the Original Code is reddit Inc. +# +# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit +# Inc. All Rights Reserved. +############################################################################### + +import datetime + +from pylons.i18n import _ +from pylons import g, c +import babel.core + +from r2.lib.menus import menu +from r2.lib.wrapped import Templated +from r2.lib.pages.pages import Reddit, TimeSeriesChart, UserList +from r2.lib.menus import NavButton, NamedButton, PageNameNav, NavMenu +from r2.lib import promote +from r2.lib.utils import Storage +from r2.models import Thing, Link, traffic +from r2.models.subreddit import Subreddit, _DefaultSR + + +COLORS = Storage(UPVOTE_ORANGE="#ff5700", + DOWNVOTE_BLUE="#9494ff", + MISC_GREEN="#00aa00") + + +class TrafficPage(Reddit): + extension_handling = False + extra_page_classes = ["traffic"] + + def __init__(self, content): + Reddit.__init__(self, title=_("traffic stats"), content=content) + + def build_toolbars(self): + main_buttons = [NavButton(menu.sitewide, "/"), + NamedButton("languages"), + NamedButton("adverts")] + + toolbar = [PageNameNav("nomenu", title=self.title), + NavMenu(main_buttons, base_path="/traffic", type="tabmenu")] + + return toolbar + + +class SitewideTrafficPage(TrafficPage): + extra_page_classes = TrafficPage.extra_page_classes + ["traffic-sitewide"] + + def __init__(self): + TrafficPage.__init__(self, SitewideTraffic()) + + +class LanguageTrafficPage(TrafficPage): + def __init__(self, langcode): + if langcode: + content = LanguageTraffic(langcode) + else: + content = LanguageTrafficSummary() + + TrafficPage.__init__(self, content) + + +class AdvertTrafficPage(TrafficPage): + def __init__(self, code): + if code: + content = AdvertTraffic(code) + else: + content = AdvertTrafficSummary() + TrafficPage.__init__(self, content) + + +class RedditTraffic(Templated): + def __init__(self, place): + self.place = place + + self.traffic_last_modified = traffic.get_traffic_last_modified() + self.traffic_lag = (datetime.datetime.utcnow() - + self.traffic_last_modified) + + self.get_tables() + + Templated.__init__(self) + + def get_tables(self): + self.tables = [] + + for interval in ("month", "day", "hour"): + columns = [ + dict(color=COLORS.UPVOTE_ORANGE, + title=_("uniques by %s" % interval), + shortname=_("uniques")), + dict(color=COLORS.DOWNVOTE_BLUE, + title=_("pageviews by %s" % interval), + shortname=_("pageviews")), + ] + + data = self.get_data_for_interval(interval, columns) + + title = _("traffic by %s" % interval) + graph = TimeSeriesChart("traffic-" + interval, + title, + interval, + columns, + data, + self.traffic_last_modified, + classes=["traffic-table"]) + self.tables.append(graph) + + try: + self.dow_summary = self.get_dow_summary() + except NotImplementedError: + self.dow_summary = None + else: + self.dow_summary = self.dow_summary[1:8] # latest complete week + mean_uniques = sum(r[1][0] for r in self.dow_summary) / 7.0 + mean_pageviews = sum(r[1][1] for r in self.dow_summary) / 7.0 + self.dow_means = (round(mean_uniques), round(mean_pageviews)) + + def get_dow_summary(self): + raise NotImplementedError() + + def get_data_for_interval(self, interval, columns): + raise NotImplementedError() + + +class SitewideTraffic(RedditTraffic): + def __init__(self): + subreddit_summary = traffic.PageviewsBySubreddit.top_last_month() + self.subreddit_summary = [] + for srname, data in subreddit_summary: + if srname == _DefaultSR.name: + name = _("[frontpage]") + url = None + elif srname in Subreddit._specials: + name = "[%s]" % srname + url = None + else: + name = "/r/%s" % srname + url = name + "/about/traffic" + + self.subreddit_summary.append(((name, url), data)) + + RedditTraffic.__init__(self, g.domain) + + def get_dow_summary(self): + return traffic.SitewidePageviews.history("day") + + def get_data_for_interval(self, interval, columns): + return traffic.SitewidePageviews.history(interval) + + +class LanguageTrafficSummary(Templated): + def __init__(self): + # convert language codes to real names + language_summary = traffic.PageviewsByLanguage.top_last_month() + locale = c.locale + self.language_summary = [] + for language_code, data in language_summary: + name = LanguageTraffic.get_language_name(language_code, locale) + self.language_summary.append(((language_code, name), data)) + + Templated.__init__(self) + + +class AdvertTrafficSummary(RedditTraffic): + def __init__(self): + RedditTraffic.__init__(self, _("adverts")) + + def get_tables(self): + # overall promoted link traffic + impressions = traffic.AdImpressionsByCodename.historical_totals("day") + clicks = traffic.ClickthroughsByCodename.historical_totals("day") + data = traffic.zip_timeseries(impressions, clicks) + + columns = [ + dict(color=COLORS.UPVOTE_ORANGE, + title=_("total impressions by day"), + shortname=_("impressions")), + dict(color=COLORS.DOWNVOTE_BLUE, + title=_("total clicks by day"), + shortname=_("clicks")), + ] + + self.totals = TimeSeriesChart("traffic-ad-totals", + _("ad totals"), + "day", + columns, + data, + self.traffic_last_modified, + classes=["traffic-table"]) + + # get summary of top ads + advert_summary = traffic.AdImpressionsByCodename.top_last_month() + things = AdvertTrafficSummary.get_things(ad for ad, data + in advert_summary) + self.advert_summary = [] + for id, data in advert_summary: + name = AdvertTrafficSummary.get_ad_name(id, things=things) + url = AdvertTrafficSummary.get_ad_url(id, things=things) + self.advert_summary.append(((name, url), data)) + + @staticmethod + def get_things(codes): + fullnames = [code for code in codes + if code.startswith(Thing._type_prefix)] + return Thing._by_fullname(fullnames, data=True, return_dict=True) + + @staticmethod + def get_sr_name(name): + if name == g.default_sr: + return _("frontpage") + else: + return "/r/" + name + + @staticmethod + def get_ad_name(code, things=None): + if not things: + things = AdvertTrafficSummary.get_things([code]) + + thing = things.get(code) + if not thing: + if code.startswith("dart_"): + srname = code.split("_", 1)[1] + srname = AdvertTrafficSummary.get_sr_name(srname) + return "DART: " + srname + else: + return code + elif isinstance(thing, Link): + return "Link: " + thing.title + elif isinstance(thing, Subreddit): + srname = AdvertTrafficSummary.get_sr_name(thing.name) + return "300x100: " + srname + + @staticmethod + def get_ad_url(code, things): + thing = things.get(code) + + if isinstance(thing, Link): + return "/traffic/%s" % thing._id36 + + return "/traffic/adverts/%s" % code + + +class LanguageTraffic(RedditTraffic): + def __init__(self, langcode): + self.langcode = langcode + name = LanguageTraffic.get_language_name(langcode) + RedditTraffic.__init__(self, name) + + def get_data_for_interval(self, interval, columns): + return traffic.PageviewsByLanguage.history(interval, self.langcode) + + @staticmethod + def get_language_name(language_code, locale=None): + if not locale: + locale = c.locale + + try: + lang_locale = babel.core.Locale.parse(language_code, sep="-") + except (babel.core.UnknownLocaleError, ValueError): + return language_code + else: + return lang_locale.get_display_name(locale) + + +class AdvertTraffic(RedditTraffic): + def __init__(self, code): + self.code = code + name = AdvertTrafficSummary.get_ad_name(code) + RedditTraffic.__init__(self, name) + + def get_data_for_interval(self, interval, columns): + columns[1]["title"] = _("impressions by %s" % interval) + columns[1]["shortname"] = _("impressions") + + columns += [ + dict(shortname=_("unique clicks")), + dict(color=COLORS.MISC_GREEN, + title=_("clicks by %s" % interval), + shortname=_("total clicks")), + ] + + imps = traffic.AdImpressionsByCodename.history(interval, self.code) + clicks = traffic.ClickthroughsByCodename.history(interval, self.code) + return traffic.zip_timeseries(imps, clicks) + + +class SubredditTraffic(RedditTraffic): + def __init__(self): + RedditTraffic.__init__(self, "/r/" + c.site.name) + + def get_dow_summary(self): + return traffic.PageviewsBySubreddit.history("day", c.site.name) + + def get_data_for_interval(self, interval, columns): + pageviews = traffic.PageviewsBySubreddit.history(interval, c.site.name) + + if interval == "day": + columns.append(dict(color=COLORS.MISC_GREEN, + title=_("subscriptions by day"), + shortname=_("subscriptions"))) + + sr_name = c.site.name + subscriptions = traffic.SubscriptionsBySubreddit.history(interval, + sr_name) + + return traffic.zip_timeseries(pageviews, subscriptions) + else: + return pageviews + + +class PromotedLinkTraffic(RedditTraffic): + def __init__(self, thing): + self.thing = thing + + editable = c.user_is_sponsor or c.user._id == thing.author_id + self.viewer_list = TrafficViewerList(thing, editable) + + RedditTraffic.__init__(self, None) + + @staticmethod + def calculate_clickthrough_rate(impressions, clicks): + if impressions: + return (float(clicks) / impressions) * 100. + else: + return 0 + + def get_tables(self): + start, end = promote.get_total_run(self.thing) + + if not start or not end: + self.history = [] + return + + fullname = self.thing._fullname + imps = traffic.AdImpressionsByCodename.promotion_history(fullname, + start, end) + clicks = traffic.ClickthroughsByCodename.promotion_history(fullname, + start, end) + + history = traffic.zip_timeseries(imps, clicks) + computed_history = [] + self.total_impressions, self.total_clicks = 0, 0 + for date, data in history: + u_imps, imps, u_clicks, clicks = data + + u_ctr = self.calculate_clickthrough_rate(u_imps, u_clicks) + ctr = self.calculate_clickthrough_rate(imps, clicks) + + self.total_impressions += imps + self.total_clicks += clicks + computed_history.append((date, data + (u_ctr, ctr))) + + self.history = computed_history + + if self.total_impressions > 0: + self.total_ctr = ((float(self.total_clicks) / + self.total_impressions) * 100.) + + # the results are preliminary until 1 day after the promotion ends + now = datetime.datetime.utcnow() + self.is_preliminary = end + datetime.timedelta(days=1) > now + + # we should only graph a sane number of data points (not everything) + self.max_points = traffic.points_for_interval("hour") + + return computed_history + + def as_csv(self): + import csv + import cStringIO + + out = cStringIO.StringIO() + writer = csv.writer(out) + + history = self.get_tables() + writer.writerow((_("date and time (UTC)"), + _("unique impressions"), + _("total impressions"), + _("unique clicks"), + _("total clicks"), + _("unique click-through rate (%)"), + _("total click-through rate (%)"))) + for date, values in history: + # flatten (date, value-tuple) to (date, value1, value2...) + writer.writerow((date,) + values) + + return out.getvalue() + + +class TrafficViewerList(UserList): + """Traffic share list on /traffic/*""" + destination = "traffic_viewer" + remove_action = "rm_traffic_viewer" + type = "traffic" + + def __init__(self, link, editable=True): + self.link = link + UserList.__init__(self, editable=editable) + + @property + def form_title(self): + return _("share traffic") + + @property + def table_title(self): + return _("current viewers") + + def user_ids(self): + return promote.traffic_viewers(self.link) + + @property + def container_name(self): + return self.link._fullname diff --git a/r2/r2/lib/promote.py b/r2/r2/lib/promote.py index e8d5d15dc..8c8321102 100644 --- a/r2/r2/lib/promote.py +++ b/r2/r2/lib/promote.py @@ -316,6 +316,13 @@ def rm_traffic_viewer(thing, user): def traffic_viewers(thing): return sorted(getattr(thing, "promo_traffic_viewers", set())) +def traffic_totals(): + from r2.models import traffic + impressions = traffic.AdImpressionsByCodename.historical_totals("day") + clicks = traffic.ClickthroughsByCodename.historical_totals("day") + traffic_data = traffic.zip_timeseries(impressions, clicks) + return [(d.date(), v) for d, v in traffic_data] + # logging routine for keeping track of diffs def promotion_log(thing, text, commit = False): """ @@ -662,7 +669,7 @@ def scheduled_campaigns_by_link(l, date=None): return accepted def get_traffic_weights(srnames): - from r2.lib import traffic + from r2.models.traffic import PageviewsBySubreddit # the weight is just the last 7 days of impressions (averaged) def weigh(t, npoints = 7): @@ -671,7 +678,7 @@ def get_traffic_weights(srnames): return max(float(sum(t)) / len(t), 1) return 1 - default_traffic = [weigh(traffic.load_traffic("day", "reddit", sr.name)) + default_traffic = [weigh(PageviewsBySubreddit.history("day", sr.name)) for sr in Subreddit.top_lang_srs('all', 10)] default_traffic = (float(max(sum(default_traffic),1)) / max(len(default_traffic), 1)) @@ -680,7 +687,7 @@ def get_traffic_weights(srnames): for srname in srnames: if srname: res[srname] = (default_traffic / - weigh(traffic.load_traffic("day", "reddit", srname)) ) + weigh(PageviewsBySubreddit.history("day", sr.name)) ) else: res[srname] = 1 return res @@ -881,6 +888,34 @@ def benchmark_promoted(user, site, pos = 0, link_sample = 50, attempts = 100): print "%s: %5.3f %3.5f" % (l,float(v)/attempts, expected.get(l, 0)) +def get_total_run(link): + """Return the total time span this promotion has run for. + + Starts at the start date of the earliest campaign and goes to the end date + of the latest campaign. + + """ + + if not link.campaigns: + return None, None + + earliest = None + latest = None + for start, end, bid, sr, trans_id in link.campaigns.itervalues(): + if not trans_id: + continue + + if not earliest or start < earliest: + earliest = start + + if not latest or end > latest: + latest = end + + # ugh this stuff is a mess. they're stored as "UTC" but actually mean UTC-5. + earliest = earliest.replace(tzinfo=None) - timezone_offset + latest = latest.replace(tzinfo=None) - timezone_offset + return earliest, latest + def Run(offset = 0): charge_pending(offset = offset + 1) diff --git a/r2/r2/lib/strings.py b/r2/r2/lib/strings.py index 4879a06ef..806cf51d1 100644 --- a/r2/r2/lib/strings.py +++ b/r2/r2/lib/strings.py @@ -171,6 +171,23 @@ string_dict = dict( account_activity_blurb = _("This page shows a history of recent activity on your account. If you notice unusual activity, you should change your password immediately. Location information is guessed from your computer's IP address and may be wildly wrong, especially for visits from mobile devices. Note: due to a bug, private-use addresses (starting with 10.) sometimes show up erroneously in this list after regular use of the site."), your_current_ip_is = _("You are currently accessing reddit from this IP address: %(address)s."), + traffic_promoted_link_explanation = _("Below you will see your promotion's impression and click traffic per hour of promotion. Please note that these traffic totals will lag behind by two to three hours, and that daily totals will be preliminary until 24 hours after the link has finished its run."), + traffic_processing_slow = _("Traffic processing is currently running slow. The latest data available is from %(date)s. This page will be updated as new data becomes available."), + traffic_processing_normal = _("Traffic processing occurs on an hourly basis. The latest data available is from %(date)s. This page will be updated as new data becomes available."), + + traffic_subreddit_explanation = _(""" +Below are the traffic statistics for your subreddit. Each graph represents one of the following over the interval specified. + +* **pageviews** are all hits to %(subreddit)s, including both listing pages and comment pages. +* **uniques** are the total number of unique visitors (determined by a combination of their IP address and User Agent string) that generate the above pageviews. This is independent of whether or not they are logged in. +* **subscriptions** is the number of new subscriptions that have been generated in a given day. This number is less accurate than the first two metrics, as, though we can track new subscriptions, we have no way to track unsubscriptions. + +Note: there are a couple of places outside of your subreddit where someone can click "subscribe", so it is possible (though unlikely) that the subscription count can exceed the unique count on a given day. +"""), + + go = _("go"), + view_subreddit_traffic = _("view subreddit traffic"), + ) class StringHandler(object): @@ -393,8 +410,18 @@ rand_strings.add('sadmessages', "Funny 500 page message", 10) rand_strings.add('create_reddit', "Reason to create a reddit", 20) -def print_rand_strings(): +def generate_strings(): + """Print out automatically generated strings for translation.""" + + # used by error pages and in the sidebar for why to create a subreddit for name, rand_string in rand_strings: for string in rand_string: print "# TRANSLATORS: Do not translate literally. Come up with a funny/relevant phrase (see the English version for ideas)" print "print _('" + string + "')" + + # these are used in r2.lib.pages.trafficpages + INTERVALS = ("hour", "day", "month") + TYPES = ("uniques", "pageviews", "traffic", "impressions", "clicks") + for interval in INTERVALS: + for type in TYPES: + print "print _('%s by %s')" % (type, interval) diff --git a/r2/r2/lib/template_helpers.py b/r2/r2/lib/template_helpers.py index fcf82e490..acb632108 100755 --- a/r2/r2/lib/template_helpers.py +++ b/r2/r2/lib/template_helpers.py @@ -33,6 +33,7 @@ import os.path from copy import copy import random import urlparse +import calendar from pylons import g, c from pylons.i18n import _, ungettext from paste.util.mimeparse import desired_matches @@ -530,3 +531,7 @@ def format_number(number, locale=None): locale = c.locale return babel.numbers.format_number(number, locale=locale) + + +def js_timestamp(date): + return '%d' % (calendar.timegm(date.timetuple()) * 1000) diff --git a/r2/r2/lib/traffic.py b/r2/r2/lib/traffic.py deleted file mode 100644 index cc6a1b1e5..000000000 --- a/r2/r2/lib/traffic.py +++ /dev/null @@ -1,92 +0,0 @@ -# The contents of this file are subject to the Common Public Attribution -# License Version 1.0. (the "License"); you may not use this file except in -# compliance with the License. You may obtain a copy of the License at -# http://code.reddit.com/LICENSE. The License is based on the Mozilla Public -# License Version 1.1, but Sections 14 and 15 have been added to cover use of -# software over a computer network and provide for limited attribution for the -# Original Developer. In addition, Exhibit A has been modified to be consistent -# with Exhibit B. -# -# Software distributed under the License is distributed on an "AS IS" basis, -# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for -# the specific language governing rights and limitations under the License. -# -# The Original Code is reddit. -# -# The Original Developer is the Initial Developer. The Initial Developer of -# the Original Code is reddit Inc. -# -# All portions of the code written by reddit are Copyright (c) 2006-2012 reddit -# Inc. All Rights Reserved. -############################################################################### - -import time -import datetime - -from r2.lib import promote -from r2.models import traffic - - -def force_datetime(dt): - if isinstance(dt, datetime.datetime): - return dt - elif isinstance(dt, datetime.date): - return datetime.datetime.combine(dt, datetime.time()) - else: - raise NotImplementedError() - - -def load_traffic(interval, what, iden="", - start_time=None, stop_time=None, - npoints=None): - if what == "reddit": - sr_traffic = traffic.PageviewsBySubreddit.history(interval, iden) - - # add in null values for cname stuff - res = [(t, v + (0, 0)) for (t, v) in sr_traffic] - - # day interval needs subscription numbers - if interval == "day": - subscriptions = traffic.SubscriptionsBySubreddit.history(interval, - iden) - res = traffic.zip_timeseries(res, subscriptions) - elif what == "total": - res = traffic.SitewidePageviews.history(interval) - elif what == "summary" and iden == "reddit" and interval == "month": - sr_traffic = traffic.PageviewsBySubreddit.top_last_month() - # add in null values for cname stuff - # return directly because this doesn't have a date parameter first - return [(t, v + (0, 0)) for (t, v) in sr_traffic] - elif what == "promos" and interval == "day": - pageviews = traffic.AdImpressionsByCodename.historical_totals(interval) - clicks = traffic.ClickthroughsByCodename.historical_totals(interval) - res = traffic.zip_timeseries(pageviews, clicks) - elif what == "thing" and interval == "hour" and start_time: - start_time = force_datetime(start_time) - promote.timezone_offset - stop_time = force_datetime(stop_time) - promote.timezone_offset - pageviews = traffic.AdImpressionsByCodename.promotion_history(iden, - start_time, - stop_time) - clicks = traffic.ClickthroughsByCodename.promotion_history(iden, - start_time, - stop_time) - res = traffic.zip_timeseries(pageviews, clicks) - elif what == "thing" and not start_time: - pageviews = traffic.AdImpressionsByCodename.history(interval, iden) - clicks = traffic.ClickthroughsByCodename.history(interval, iden) - res = traffic.zip_timeseries(pageviews, clicks) - else: - raise NotImplementedError() - - if interval == "hour": - # convert to local time - tzoffset = datetime.timedelta(0, time.timezone) - res = [(d - tzoffset, v) for d, v in res] - else: - res = [(d.date(), v) for d, v in res] - - return res - - -def load_summary(what, interval = "month", npoints = 50): - return load_traffic(interval, "summary", what, npoints = npoints) diff --git a/r2/r2/lib/utils/utils.py b/r2/r2/lib/utils/utils.py index 8e536329e..9b607f4a8 100644 --- a/r2/r2/lib/utils/utils.py +++ b/r2/r2/lib/utils/utils.py @@ -1097,15 +1097,6 @@ def make_offset_date(start_date, interval, future = True, return start_date - timedelta(interval) return start_date -def to_csv(table): - # commas and linebreaks must result in a quoted string - def quote_commas(x): - if ',' in x or '\n' in x: - return u'"%s"' % x.replace('"', '""') - return x - return u"\n".join(u','.join(quote_commas(y) for y in x) - for x in table) - def in_chunks(it, size=25): chunk = [] it = iter(it) diff --git a/r2/r2/models/traffic.py b/r2/r2/models/traffic.py index 89f7a4cf2..dcd846be3 100644 --- a/r2/r2/models/traffic.py +++ b/r2/r2/models/traffic.py @@ -189,6 +189,13 @@ def time_range(interval): return start_time, stop_time +def points_for_interval(interval): + """Calculate the number of data points to render for a given interval.""" + range = time_range_by_interval[interval] + interval = timedelta_by_name(interval) + return range.total_seconds() / interval.total_seconds() + + def make_history_query(cls, interval): """Build a generic query showing the history of a given aggregate.""" diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index 8755cf1f1..9386913a5 100755 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -3821,46 +3821,97 @@ ul.tabmenu.formtab { } /***traffic stuff***/ -.traffic-table {margin: 10px 20px; } +.traffic-table, +.traffic-tables-side fieldset { + margin: 1.5em 2em; + font-size: small; + border: 0; +} + +.traffic-table caption, +.traffic-tables-side fieldset legend{ + font-weight: bold; + text-align: left; + font-size: medium; + font-variant: small-caps; +} + .traffic-table a:hover { text-decoration: underline; } -.traffic-table th { font-weight: bold; text-align: center;} -.traffic-table th, +.traffic-table thead th { font-weight: bold; text-align: center; padding-left: 2em;} +.traffic-table thead th:first-child { text-align: left; padding-left: 0; } +.traffic-table tbody th, +.traffic-table tfoot th { text-align: left;} .traffic-table td { padding: 0 5px; } .traffic-table td { text-align: right; } -.traffic-table td.up { color: #FF8B60; } -.traffic-table td.down { color: #336699; } -.traffic-table td.gray { color: gray; font-style: italic; } +.traffic-table tfoot tr { border-top: 1px solid black; } +.traffic-table tfoot th, +.traffic-table tfoot td { font-style: italic; } .traffic-table tr.max { border: 2px solid #FF8B60; } .traffic-table tr.min { border: 2px solid #336699; } -.traffic-table tr.odd { background-color: #E0E0E0; } +.traffic-table tbody tr:nth-child(even) { background-color: #E0E0E0; } .traffic-table tr.mean { font-style: italic; border-top: 1px solid; } -.traffic-table .prelim { font-style: italic; } -.traffic-table .totals { font-style: italic; border-top: 1px solid black; } - -.traffic-graph { - padding: 10px; - border: 1px solid #B0B0B0; - margin-left: 10px; - margin-bottom: 10px; - display: inline-block; +.traffic-tables-side { + float: left; + min-height: 50em; } -.traffic-graph .title { + +#promote-graph-table, +#traffic-hour { + display: none; +} + +div.timeseries { + padding: 10px; + border: 1px solid #B0B0B0; + margin: 10px 10px; + display: inline-block; text-align: center; } +.timeseries-placeholder { + width: 350px; + height: 200px; + font-family: verdana; + font-size: small; +} +div.timeseries span.title { + font-weight: bold; + font-size: medium; + font-variant: small-caps; +} -.promoted-traffic h1 { - border: none; - margin-bottom: 10px; +#timeseries-unprocessed { + font-size: small; + font-weight: bold; + color: #900; + margin: 1em 0; + max-width: 60em; +} + +.timeseries-tablebar { + height: 5px; + margin: 1px 0; } .promoted-traffic .usertable { margin-left: 0px; } -.promoted-traffic h1 a { +.promoted-traffic h1 a { font-size: small; - margin-left: 10px; + margin-left: 10px; +} + +.promoted-traffic tfoot th, +.promoted-traffic tfoot td { + font-style: normal; + font-weight: bold; + text-transform: uppercase; + padding-top: .3em; +} + +p.totals-are-preliminary { + margin-left: 10px; } .award-square-container { diff --git a/r2/r2/public/static/js/lib/excanvas.min.js b/r2/r2/public/static/js/lib/excanvas.min.js new file mode 100644 index 000000000..fcf876c74 --- /dev/null +++ b/r2/r2/public/static/js/lib/excanvas.min.js @@ -0,0 +1 @@ +if(!document.createElement("canvas").getContext){(function(){var ab=Math;var n=ab.round;var l=ab.sin;var A=ab.cos;var H=ab.abs;var N=ab.sqrt;var d=10;var f=d/2;var z=+navigator.userAgent.match(/MSIE ([\d.]+)?/)[1];function y(){return this.context_||(this.context_=new D(this))}var t=Array.prototype.slice;function g(j,m,p){var i=t.call(arguments,2);return function(){return j.apply(m,i.concat(t.call(arguments)))}}function af(i){return String(i).replace(/&/g,"&").replace(/"/g,""")}function Y(m,j,i){if(!m.namespaces[j]){m.namespaces.add(j,i,"#default#VML")}}function R(j){Y(j,"g_vml_","urn:schemas-microsoft-com:vml");Y(j,"g_o_","urn:schemas-microsoft-com:office:office");if(!j.styleSheets.ex_canvas_){var i=j.createStyleSheet();i.owningElement.id="ex_canvas_";i.cssText="canvas{display:inline-block;overflow:hidden;text-align:left;width:300px;height:150px}"}}R(document);var e={init:function(i){var j=i||document;j.createElement("canvas");j.attachEvent("onreadystatechange",g(this.init_,this,j))},init_:function(p){var m=p.getElementsByTagName("canvas");for(var j=0;j1){m--}if(6*m<1){return j+(i-j)*6*m}else{if(2*m<1){return i}else{if(3*m<2){return j+(i-j)*(2/3-m)*6}else{return j}}}}var C={};function F(j){if(j in C){return C[j]}var ag,Z=1;j=String(j);if(j.charAt(0)=="#"){ag=j}else{if(/^rgb/.test(j)){var p=M(j);var ag="#",ah;for(var m=0;m<3;m++){if(p[m].indexOf("%")!=-1){ah=Math.floor(c(p[m])*255)}else{ah=+p[m]}ag+=k[r(ah,0,255)]}Z=+p[3]}else{if(/^hsl/.test(j)){var p=M(j);ag=I(p);Z=p[3]}else{ag=b[j]||j}}}return C[j]={color:ag,alpha:Z}}var o={style:"normal",variant:"normal",weight:"normal",size:10,family:"sans-serif"};var L={};function E(i){if(L[i]){return L[i]}var p=document.createElement("div");var m=p.style;try{m.font=i}catch(j){}return L[i]={style:m.fontStyle||o.style,variant:m.fontVariant||o.variant,weight:m.fontWeight||o.weight,size:m.fontSize||o.size,family:m.fontFamily||o.family}}function u(m,j){var i={};for(var ah in m){i[ah]=m[ah]}var ag=parseFloat(j.currentStyle.fontSize),Z=parseFloat(m.size);if(typeof m.size=="number"){i.size=m.size}else{if(m.size.indexOf("px")!=-1){i.size=Z}else{if(m.size.indexOf("em")!=-1){i.size=ag*Z}else{if(m.size.indexOf("%")!=-1){i.size=(ag/100)*Z}else{if(m.size.indexOf("pt")!=-1){i.size=Z/0.75}else{i.size=ag}}}}}i.size*=0.981;return i}function ac(i){return i.style+" "+i.variant+" "+i.weight+" "+i.size+"px "+i.family}var s={butt:"flat",round:"round"};function S(i){return s[i]||"square"}function D(i){this.m_=B();this.mStack_=[];this.aStack_=[];this.currentPath_=[];this.strokeStyle="#000";this.fillStyle="#000";this.lineWidth=1;this.lineJoin="miter";this.lineCap="butt";this.miterLimit=d*1;this.globalAlpha=1;this.font="10px sans-serif";this.textAlign="left";this.textBaseline="alphabetic";this.canvas=i;var m="width:"+i.clientWidth+"px;height:"+i.clientHeight+"px;overflow:hidden;position:absolute";var j=i.ownerDocument.createElement("div");j.style.cssText=m;i.appendChild(j);var p=j.cloneNode(false);p.style.backgroundColor="red";p.style.filter="alpha(opacity=0)";i.appendChild(p);this.element_=j;this.arcScaleX_=1;this.arcScaleY_=1;this.lineScale_=1}var q=D.prototype;q.clearRect=function(){if(this.textMeasureEl_){this.textMeasureEl_.removeNode(true);this.textMeasureEl_=null}this.element_.innerHTML=""};q.beginPath=function(){this.currentPath_=[]};q.moveTo=function(j,i){var m=V(this,j,i);this.currentPath_.push({type:"moveTo",x:m.x,y:m.y});this.currentX_=m.x;this.currentY_=m.y};q.lineTo=function(j,i){var m=V(this,j,i);this.currentPath_.push({type:"lineTo",x:m.x,y:m.y});this.currentX_=m.x;this.currentY_=m.y};q.bezierCurveTo=function(m,j,ak,aj,ai,ag){var i=V(this,ai,ag);var ah=V(this,m,j);var Z=V(this,ak,aj);K(this,ah,Z,i)};function K(i,Z,m,j){i.currentPath_.push({type:"bezierCurveTo",cp1x:Z.x,cp1y:Z.y,cp2x:m.x,cp2y:m.y,x:j.x,y:j.y});i.currentX_=j.x;i.currentY_=j.y}q.quadraticCurveTo=function(ai,m,j,i){var ah=V(this,ai,m);var ag=V(this,j,i);var aj={x:this.currentX_+2/3*(ah.x-this.currentX_),y:this.currentY_+2/3*(ah.y-this.currentY_)};var Z={x:aj.x+(ag.x-this.currentX_)/3,y:aj.y+(ag.y-this.currentY_)/3};K(this,aj,Z,ag)};q.arc=function(al,aj,ak,ag,j,m){ak*=d;var ap=m?"at":"wa";var am=al+A(ag)*ak-f;var ao=aj+l(ag)*ak-f;var i=al+A(j)*ak-f;var an=aj+l(j)*ak-f;if(am==i&&!m){am+=0.125}var Z=V(this,al,aj);var ai=V(this,am,ao);var ah=V(this,i,an);this.currentPath_.push({type:ap,x:Z.x,y:Z.y,radius:ak,xStart:ai.x,yStart:ai.y,xEnd:ah.x,yEnd:ah.y})};q.rect=function(m,j,i,p){this.moveTo(m,j);this.lineTo(m+i,j);this.lineTo(m+i,j+p);this.lineTo(m,j+p);this.closePath()};q.strokeRect=function(m,j,i,p){var Z=this.currentPath_;this.beginPath();this.moveTo(m,j);this.lineTo(m+i,j);this.lineTo(m+i,j+p);this.lineTo(m,j+p);this.closePath();this.stroke();this.currentPath_=Z};q.fillRect=function(m,j,i,p){var Z=this.currentPath_;this.beginPath();this.moveTo(m,j);this.lineTo(m+i,j);this.lineTo(m+i,j+p);this.lineTo(m,j+p);this.closePath();this.fill();this.currentPath_=Z};q.createLinearGradient=function(j,p,i,m){var Z=new U("gradient");Z.x0_=j;Z.y0_=p;Z.x1_=i;Z.y1_=m;return Z};q.createRadialGradient=function(p,ag,m,j,Z,i){var ah=new U("gradientradial");ah.x0_=p;ah.y0_=ag;ah.r0_=m;ah.x1_=j;ah.y1_=Z;ah.r1_=i;return ah};q.drawImage=function(aq,m){var aj,ah,al,ay,ao,am,at,aA;var ak=aq.runtimeStyle.width;var ap=aq.runtimeStyle.height;aq.runtimeStyle.width="auto";aq.runtimeStyle.height="auto";var ai=aq.width;var aw=aq.height;aq.runtimeStyle.width=ak;aq.runtimeStyle.height=ap;if(arguments.length==3){aj=arguments[1];ah=arguments[2];ao=am=0;at=al=ai;aA=ay=aw}else{if(arguments.length==5){aj=arguments[1];ah=arguments[2];al=arguments[3];ay=arguments[4];ao=am=0;at=ai;aA=aw}else{if(arguments.length==9){ao=arguments[1];am=arguments[2];at=arguments[3];aA=arguments[4];aj=arguments[5];ah=arguments[6];al=arguments[7];ay=arguments[8]}else{throw Error("Invalid number of arguments")}}}var az=V(this,aj,ah);var p=at/2;var j=aA/2;var ax=[];var i=10;var ag=10;ax.push(" ','","");this.element_.insertAdjacentHTML("BeforeEnd",ax.join(""))};q.stroke=function(ao){var Z=10;var ap=10;var ag=5000;var ai={x:null,y:null};var an={x:null,y:null};for(var aj=0;ajan.x){an.x=m.x}if(ai.y==null||m.yan.y){an.y=m.y}}}am.push(' ">');if(!ao){w(this,am)}else{G(this,am,ai,an)}am.push("");this.element_.insertAdjacentHTML("beforeEnd",am.join(""))}};function w(m,ag){var j=F(m.strokeStyle);var p=j.color;var Z=j.alpha*m.globalAlpha;var i=m.lineScale_*m.lineWidth;if(i<1){Z*=i}ag.push("')}function G(aq,ai,aK,ar){var aj=aq.fillStyle;var aB=aq.arcScaleX_;var aA=aq.arcScaleY_;var j=ar.x-aK.x;var p=ar.y-aK.y;if(aj instanceof U){var an=0;var aF={x:0,y:0};var ax=0;var am=1;if(aj.type_=="gradient"){var al=aj.x0_/aB;var m=aj.y0_/aA;var ak=aj.x1_/aB;var aM=aj.y1_/aA;var aJ=V(aq,al,m);var aI=V(aq,ak,aM);var ag=aI.x-aJ.x;var Z=aI.y-aJ.y;an=Math.atan2(ag,Z)*180/Math.PI;if(an<0){an+=360}if(an<0.000001){an=0}}else{var aJ=V(aq,aj.x0_,aj.y0_);aF={x:(aJ.x-aK.x)/j,y:(aJ.y-aK.y)/p};j/=aB*d;p/=aA*d;var aD=ab.max(j,p);ax=2*aj.r0_/aD;am=2*aj.r1_/aD-ax}var av=aj.colors_;av.sort(function(aN,i){return aN.offset-i.offset});var ap=av.length;var au=av[0].color;var at=av[ap-1].color;var az=av[0].alpha*aq.globalAlpha;var ay=av[ap-1].alpha*aq.globalAlpha;var aE=[];for(var aH=0;aH')}else{if(aj instanceof T){if(j&&p){var ah=-aK.x;var aC=-aK.y;ai.push("')}}else{var aL=F(aq.fillStyle);var aw=aL.color;var aG=aL.alpha*aq.globalAlpha;ai.push('')}}}q.fill=function(){this.stroke(true)};q.closePath=function(){this.currentPath_.push({type:"close"})};function V(j,Z,p){var i=j.m_;return{x:d*(Z*i[0][0]+p*i[1][0]+i[2][0])-f,y:d*(Z*i[0][1]+p*i[1][1]+i[2][1])-f}}q.save=function(){var i={};v(this,i);this.aStack_.push(i);this.mStack_.push(this.m_);this.m_=J(B(),this.m_)};q.restore=function(){if(this.aStack_.length){v(this.aStack_.pop(),this);this.m_=this.mStack_.pop()}};function h(i){return isFinite(i[0][0])&&isFinite(i[0][1])&&isFinite(i[1][0])&&isFinite(i[1][1])&&isFinite(i[2][0])&&isFinite(i[2][1])}function aa(j,i,p){if(!h(i)){return}j.m_=i;if(p){var Z=i[0][0]*i[1][1]-i[0][1]*i[1][0];j.lineScale_=N(H(Z))}}q.translate=function(m,j){var i=[[1,0,0],[0,1,0],[m,j,1]];aa(this,J(i,this.m_),false)};q.rotate=function(j){var p=A(j);var m=l(j);var i=[[p,m,0],[-m,p,0],[0,0,1]];aa(this,J(i,this.m_),false)};q.scale=function(m,j){this.arcScaleX_*=m;this.arcScaleY_*=j;var i=[[m,0,0],[0,j,0],[0,0,1]];aa(this,J(i,this.m_),true)};q.transform=function(Z,p,ah,ag,j,i){var m=[[Z,p,0],[ah,ag,0],[j,i,1]];aa(this,J(m,this.m_),true)};q.setTransform=function(ag,Z,ai,ah,p,j){var i=[[ag,Z,0],[ai,ah,0],[p,j,1]];aa(this,i,true)};q.drawText_=function(am,ak,aj,ap,ai){var ao=this.m_,at=1000,j=0,ar=at,ah={x:0,y:0},ag=[];var i=u(E(this.font),this.element_);var p=ac(i);var au=this.element_.currentStyle;var Z=this.textAlign.toLowerCase();switch(Z){case"left":case"center":case"right":break;case"end":Z=au.direction=="ltr"?"right":"left";break;case"start":Z=au.direction=="rtl"?"right":"left";break;default:Z="left"}switch(this.textBaseline){case"hanging":case"top":ah.y=i.size/1.75;break;case"middle":break;default:case null:case"alphabetic":case"ideographic":case"bottom":ah.y=-i.size/2.25;break}switch(Z){case"right":j=at;ar=0.05;break;case"center":j=ar=at/2;break}var aq=V(this,ak+ah.x,aj+ah.y);ag.push('');if(ai){w(this,ag)}else{G(this,ag,{x:-j,y:0},{x:ar,y:i.size})}var an=ao[0][0].toFixed(3)+","+ao[1][0].toFixed(3)+","+ao[0][1].toFixed(3)+","+ao[1][1].toFixed(3)+",0,0";var al=n(aq.x/d)+","+n(aq.y/d);ag.push('','','');this.element_.insertAdjacentHTML("beforeEnd",ag.join(""))};q.fillText=function(m,i,p,j){this.drawText_(m,i,p,j,false)};q.strokeText=function(m,i,p,j){this.drawText_(m,i,p,j,true)};q.measureText=function(m){if(!this.textMeasureEl_){var i='';this.element_.insertAdjacentHTML("beforeEnd",i);this.textMeasureEl_=this.element_.lastChild}var j=this.element_.ownerDocument;this.textMeasureEl_.innerHTML="";this.textMeasureEl_.style.font=this.font;this.textMeasureEl_.appendChild(j.createTextNode(m));return{width:this.textMeasureEl_.offsetWidth}};q.clip=function(){};q.arcTo=function(){};q.createPattern=function(j,i){return new T(j,i)};function U(i){this.type_=i;this.x0_=0;this.y0_=0;this.r0_=0;this.x1_=0;this.y1_=0;this.r1_=0;this.colors_=[]}U.prototype.addColorStop=function(j,i){i=F(i);this.colors_.push({offset:j,color:i.color,alpha:i.alpha})};function T(j,i){Q(j);switch(i){case"repeat":case null:case"":this.repetition_="repeat";break;case"repeat-x":case"repeat-y":case"no-repeat":this.repetition_=i;break;default:O("SYNTAX_ERR")}this.src_=j.src;this.width_=j.width;this.height_=j.height}function O(i){throw new P(i)}function Q(i){if(!i||i.nodeType!=1||i.tagName!="IMG"){O("TYPE_MISMATCH_ERR")}if(i.readyState!="complete"){O("INVALID_STATE_ERR")}}function P(i){this.code=this[i];this.message=i+": DOM Exception "+this.code}var X=P.prototype=new Error;X.INDEX_SIZE_ERR=1;X.DOMSTRING_SIZE_ERR=2;X.HIERARCHY_REQUEST_ERR=3;X.WRONG_DOCUMENT_ERR=4;X.INVALID_CHARACTER_ERR=5;X.NO_DATA_ALLOWED_ERR=6;X.NO_MODIFICATION_ALLOWED_ERR=7;X.NOT_FOUND_ERR=8;X.NOT_SUPPORTED_ERR=9;X.INUSE_ATTRIBUTE_ERR=10;X.INVALID_STATE_ERR=11;X.SYNTAX_ERR=12;X.INVALID_MODIFICATION_ERR=13;X.NAMESPACE_ERR=14;X.INVALID_ACCESS_ERR=15;X.VALIDATION_ERR=16;X.TYPE_MISMATCH_ERR=17;G_vmlCanvasManager=e;CanvasRenderingContext2D=D;CanvasGradient=U;CanvasPattern=T;DOMException=P})()}; \ No newline at end of file diff --git a/r2/r2/public/static/js/lib/jquery.flot.js b/r2/r2/public/static/js/lib/jquery.flot.js index 6534a4685..0817fd8ef 100644 --- a/r2/r2/public/static/js/lib/jquery.flot.js +++ b/r2/r2/public/static/js/lib/jquery.flot.js @@ -1,4 +1,4 @@ -/* Javascript plotting library for jQuery, v. 0.6. +/*! Javascript plotting library for jQuery, v. 0.7. * * Released under the MIT license by IOLA, December 2007. * @@ -9,7 +9,7 @@ /* Plugin for jQuery for working with colors. * - * Version 1.0. + * Version 1.1. * * Inspiration from jQuery color animation plugin by John Resig. * @@ -22,10 +22,13 @@ * console.log(c.r, c.g, c.b, c.a); * $.color.make(100, 50, 25, 0.4).toString() // returns "rgba(100,50,25,0.4)" * - * Note that .scale() and .add() work in-place instead of returning - * new objects. + * Note that .scale() and .add() return the same modified object + * instead of making a new one. + * + * V. 1.1: Fix error handling so e.g. parsing an empty string does + * produce a color rather than just crashing. */ -(function(){jQuery.color={};jQuery.color.make=function(E,D,B,C){var F={};F.r=E||0;F.g=D||0;F.b=B||0;F.a=C!=null?C:1;F.add=function(I,H){for(var G=0;G=1){return"rgb("+[F.r,F.g,F.b].join(",")+")"}else{return"rgba("+[F.r,F.g,F.b,F.a].join(",")+")"}};F.normalize=function(){function G(I,J,H){return JH?H:J)}F.r=G(0,parseInt(F.r),255);F.g=G(0,parseInt(F.g),255);F.b=G(0,parseInt(F.b),255);F.a=G(0,F.a,1);return F};F.clone=function(){return jQuery.color.make(F.r,F.b,F.g,F.a)};return F.normalize()};jQuery.color.extract=function(C,B){var D;do{D=C.css(B).toLowerCase();if(D!=""&&D!="transparent"){break}C=C.parent()}while(!jQuery.nodeName(C.get(0),"body"));if(D=="rgba(0, 0, 0, 0)"){D="transparent"}return jQuery.color.parse(D)};jQuery.color.parse=function(E){var D,B=jQuery.color.make;if(D=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(E)){return B(parseInt(D[1],10),parseInt(D[2],10),parseInt(D[3],10))}if(D=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(E)){return B(parseInt(D[1],10),parseInt(D[2],10),parseInt(D[3],10),parseFloat(D[4]))}if(D=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(E)){return B(parseFloat(D[1])*2.55,parseFloat(D[2])*2.55,parseFloat(D[3])*2.55)}if(D=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(E)){return B(parseFloat(D[1])*2.55,parseFloat(D[2])*2.55,parseFloat(D[3])*2.55,parseFloat(D[4]))}if(D=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(E)){return B(parseInt(D[1],16),parseInt(D[2],16),parseInt(D[3],16))}if(D=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(E)){return B(parseInt(D[1]+D[1],16),parseInt(D[2]+D[2],16),parseInt(D[3]+D[3],16))}var C=jQuery.trim(E).toLowerCase();if(C=="transparent"){return B(255,255,255,0)}else{D=A[C];return B(D[0],D[1],D[2])}};var A={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(); +(function(B){B.color={};B.color.make=function(F,E,C,D){var G={};G.r=F||0;G.g=E||0;G.b=C||0;G.a=D!=null?D:1;G.add=function(J,I){for(var H=0;H=1){return"rgb("+[G.r,G.g,G.b].join(",")+")"}else{return"rgba("+[G.r,G.g,G.b,G.a].join(",")+")"}};G.normalize=function(){function H(J,K,I){return KI?I:K)}G.r=H(0,parseInt(G.r),255);G.g=H(0,parseInt(G.g),255);G.b=H(0,parseInt(G.b),255);G.a=H(0,G.a,1);return G};G.clone=function(){return B.color.make(G.r,G.b,G.g,G.a)};return G.normalize()};B.color.extract=function(D,C){var E;do{E=D.css(C).toLowerCase();if(E!=""&&E!="transparent"){break}D=D.parent()}while(!B.nodeName(D.get(0),"body"));if(E=="rgba(0, 0, 0, 0)"){E="transparent"}return B.color.parse(E)};B.color.parse=function(F){var E,C=B.color.make;if(E=/rgb\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10))}if(E=/rgba\(\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]{1,3})\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseInt(E[1],10),parseInt(E[2],10),parseInt(E[3],10),parseFloat(E[4]))}if(E=/rgb\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55)}if(E=/rgba\(\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\%\s*,\s*([0-9]+(?:\.[0-9]+)?)\s*\)/.exec(F)){return C(parseFloat(E[1])*2.55,parseFloat(E[2])*2.55,parseFloat(E[3])*2.55,parseFloat(E[4]))}if(E=/#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})/.exec(F)){return C(parseInt(E[1],16),parseInt(E[2],16),parseInt(E[3],16))}if(E=/#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])/.exec(F)){return C(parseInt(E[1]+E[1],16),parseInt(E[2]+E[2],16),parseInt(E[3]+E[3],16))}var D=B.trim(F).toLowerCase();if(D=="transparent"){return C(255,255,255,0)}else{E=A[D]||[0,0,0];return C(E[0],E[1],E[2])}};var A={aqua:[0,255,255],azure:[240,255,255],beige:[245,245,220],black:[0,0,0],blue:[0,0,255],brown:[165,42,42],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgrey:[169,169,169],darkgreen:[0,100,0],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkviolet:[148,0,211],fuchsia:[255,0,255],gold:[255,215,0],green:[0,128,0],indigo:[75,0,130],khaki:[240,230,140],lightblue:[173,216,230],lightcyan:[224,255,255],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightyellow:[255,255,224],lime:[0,255,0],magenta:[255,0,255],maroon:[128,0,0],navy:[0,0,128],olive:[128,128,0],orange:[255,165,0],pink:[255,192,203],purple:[128,0,128],violet:[128,0,128],red:[255,0,0],silver:[192,192,192],white:[255,255,255],yellow:[255,255,0]}})(jQuery); // the actual Flot code (function($) { @@ -51,7 +54,13 @@ backgroundOpacity: 0.85 // set to 0 to avoid background }, xaxis: { + show: null, // null = auto-detect, true = always, false = never + position: "bottom", // or "top" mode: null, // null or "time" + timezone: null, // "browser" for local to the client or timezone for timezone-js + font: null, // null (derived from CSS in placeholder) or object like { size: 11, style: "italic", weight: "bold", family: "sans-serif", variant: "small-caps" } + color: null, // base color, labels, ticks + tickColor: null, // possibly different color of ticks, e.g. "rgba(0,0,0,0.15)" transform: null, // null or f: number -> number to transform axis inverseTransform: null, // if transform is set, this should be the inverse function min: null, // min. value to show, null means set automatically @@ -61,6 +70,9 @@ tickFormatter: null, // fn: number -> string labelWidth: null, // size of tick labels in pixels labelHeight: null, + reserveSpace: null, // whether to reserve space even if axis isn't shown + tickLength: null, // size in pixels of ticks, or "full" for whole line + alignTicksWithAxis: null, // axis number or null for no sync // mode specific options tickDecimals: null, // no. of decimals, null means auto @@ -71,21 +83,19 @@ twelveHourClock: false // 12 or 24 time in time mode }, yaxis: { - autoscaleMargin: 0.02 - }, - x2axis: { - autoscaleMargin: null - }, - y2axis: { - autoscaleMargin: 0.02 + autoscaleMargin: 0.02, + position: "left" // or "right" }, + xaxes: [], + yaxes: [], series: { points: { show: false, radius: 3, lineWidth: 2, // in pixels fill: true, - fillColor: "#ffffff" + fillColor: "#ffffff", + symbol: "circle" // or callback }, lines: { // we don't put in show: false so we can see @@ -101,8 +111,8 @@ barWidth: 1, // in units of the x axis fill: true, fillColor: null, - align: "left", // or "center" - horizontal: false // when horizontal, left is now top + align: "left", // "left", "right", or "center" + horizontal: false }, shadowSize: 3 }, @@ -111,10 +121,13 @@ aboveData: false, color: "#545454", // primary color used for outline and labels backgroundColor: null, // null for transparent, else color - tickColor: "rgba(0,0,0,0.15)", // color used for the ticks - labelMargin: 5, // in pixels - borderWidth: 2, // in pixels borderColor: null, // set if different from the grid color + tickColor: null, // color for the ticks, e.g. "rgba(0,0,0,0.15)" + margin: 0, // distance from the canvas edge to the grid + labelMargin: 5, // in pixels + axisMargin: 8, // in pixels + borderWidth: 2, // in pixels + minBorderMargin: null, // in pixels, null means taken from points radius markings: null, // array of ranges or fn: axes -> array of ranges markingsColor: "#f4f4f4", markingsLineWidth: 2, @@ -124,13 +137,16 @@ autoHighlight: true, // highlight in case mouse is near mouseActiveRadius: 10 // how far the mouse can be away to activate an item }, + interaction: { + redrawOverlayInterval: 1000/60 // time between updates, -1 means in same flow + }, hooks: {} }, canvas = null, // the canvas for the plot itself overlay = null, // canvas for interactive stuff on top of plot eventHolder = null, // jQuery object that events should be bound to ctx = null, octx = null, - axes = { xaxis: {}, yaxis: {}, x2axis: {}, y2axis: {} }, + xaxes = [], yaxes = [], plotOffset = { left: 0, right: 0, top: 0, bottom: 0}, canvasWidth = 0, canvasHeight = 0, plotWidth = 0, plotHeight = 0, @@ -138,9 +154,13 @@ processOptions: [], processRawData: [], processDatapoints: [], + processOffset: [], + drawBackground: [], + drawSeries: [], draw: [], bindEvents: [], - drawOverlay: [] + drawOverlay: [], + shutdown: [] }, plot = this; @@ -159,17 +179,35 @@ o.top += plotOffset.top; return o; }; - plot.getData = function() { return series; }; - plot.getAxes = function() { return axes; }; - plot.getOptions = function() { return options; }; + plot.getData = function () { return series; }; + plot.getAxes = function () { + var res = {}, i; + $.each(xaxes.concat(yaxes), function (_, axis) { + if (axis) + res[axis.direction + (axis.n != 1 ? axis.n : "") + "axis"] = axis; + }); + return res; + }; + plot.getXAxes = function () { return xaxes; }; + plot.getYAxes = function () { return yaxes; }; + plot.c2p = canvasToAxisCoords; + plot.p2c = axisToCanvasCoords; + plot.getOptions = function () { return options; }; plot.highlight = highlight; plot.unhighlight = unhighlight; plot.triggerRedrawOverlay = triggerRedrawOverlay; plot.pointOffset = function(point) { - return { left: parseInt(axisSpecToRealAxis(point, "xaxis").p2c(+point.x) + plotOffset.left), - top: parseInt(axisSpecToRealAxis(point, "yaxis").p2c(+point.y) + plotOffset.top) }; + return { + left: parseInt(xaxes[axisNumber(point, "x") - 1].p2c(+point.x) + plotOffset.left), + top: parseInt(yaxes[axisNumber(point, "y") - 1].p2c(+point.y) + plotOffset.top) + }; + }; + plot.shutdown = shutdown; + plot.resize = function () { + getCanvasDimensions(); + resizeCanvas(canvas); + resizeCanvas(overlay); }; - // public attributes plot.hooks = hooks; @@ -177,7 +215,7 @@ // initialize initPlugins(plot); parseOptions(options_); - constructCanvas(); + setupCanvases(); setData(data_); setupGrid(); draw(); @@ -200,14 +238,45 @@ } function parseOptions(opts) { + var i; + $.extend(true, options, opts); + + if (options.xaxis.color == null) + options.xaxis.color = options.grid.color; + if (options.yaxis.color == null) + options.yaxis.color = options.grid.color; + + if (options.xaxis.tickColor == null) // backwards-compatibility + options.xaxis.tickColor = options.grid.tickColor; + if (options.yaxis.tickColor == null) // backwards-compatibility + options.yaxis.tickColor = options.grid.tickColor; + if (options.grid.borderColor == null) options.grid.borderColor = options.grid.color; + if (options.grid.tickColor == null) + options.grid.tickColor = $.color.parse(options.grid.color).scale('a', 0.22).toString(); + + // fill in defaults in axes, copy at least always the + // first as the rest of the code assumes it'll be there + for (i = 0; i < Math.max(1, options.xaxes.length); ++i) + options.xaxes[i] = $.extend(true, {}, options.xaxis, options.xaxes[i]); + for (i = 0; i < Math.max(1, options.yaxes.length); ++i) + options.yaxes[i] = $.extend(true, {}, options.yaxis, options.yaxes[i]); + // backwards compatibility, to be removed in future if (options.xaxis.noTicks && options.xaxis.ticks == null) options.xaxis.ticks = options.xaxis.noTicks; if (options.yaxis.noTicks && options.yaxis.ticks == null) options.yaxis.ticks = options.yaxis.noTicks; + if (options.x2axis) { + options.xaxes[1] = $.extend(true, {}, options.xaxis, options.x2axis); + options.xaxes[1].position = "top"; + } + if (options.y2axis) { + options.yaxes[1] = $.extend(true, {}, options.yaxis, options.y2axis); + options.yaxes[1].position = "right"; + } if (options.grid.coloredAreas) options.grid.markings = options.grid.coloredAreas; if (options.grid.coloredAreasColor) @@ -218,9 +287,16 @@ $.extend(true, options.series.points, options.points); if (options.bars) $.extend(true, options.series.bars, options.bars); - if (options.shadowSize) + if (options.shadowSize != null) options.series.shadowSize = options.shadowSize; + // save options on axes for future reference + for (i = 0; i < options.xaxes.length; ++i) + getOrCreateAxis(xaxes, i + 1).options = options.xaxes[i]; + for (i = 0; i < options.yaxes.length; ++i) + getOrCreateAxis(yaxes, i + 1).options = options.yaxes[i]; + + // add hooks from options for (var n in hooks) if (options.hooks[n] && options.hooks[n].length) hooks[n] = hooks[n].concat(options.hooks[n]); @@ -239,7 +315,7 @@ for (var i = 0; i < d.length; ++i) { var s = $.extend(true, {}, options.series); - if (d[i].data) { + if (d[i].data != null) { s.data = d[i].data; // move the data instead of deep-copy delete d[i].data; @@ -255,15 +331,89 @@ return res; } - function axisSpecToRealAxis(obj, attr) { - var a = obj[attr]; - if (!a || a == 1) - return axes[attr]; - if (typeof a == "number") - return axes[attr.charAt(0) + a + attr.slice(1)]; - return a; // assume it's OK + function axisNumber(obj, coord) { + var a = obj[coord + "axis"]; + if (typeof a == "object") // if we got a real axis, extract number + a = a.n; + if (typeof a != "number") + a = 1; // default to first axis + return a; + } + + function allAxes() { + // return flat array without annoying null entries + return $.grep(xaxes.concat(yaxes), function (a) { return a; }); } + function canvasToAxisCoords(pos) { + // return an object with x/y corresponding to all used axes + var res = {}, i, axis; + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) + res["x" + axis.n] = axis.c2p(pos.left); + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) + res["y" + axis.n] = axis.c2p(pos.top); + } + + if (res.x1 !== undefined) + res.x = res.x1; + if (res.y1 !== undefined) + res.y = res.y1; + + return res; + } + + function axisToCanvasCoords(pos) { + // get canvas coords from the first pair of x/y found in pos + var res = {}, i, axis, key; + + for (i = 0; i < xaxes.length; ++i) { + axis = xaxes[i]; + if (axis && axis.used) { + key = "x" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "x"; + + if (pos[key] != null) { + res.left = axis.p2c(pos[key]); + break; + } + } + } + + for (i = 0; i < yaxes.length; ++i) { + axis = yaxes[i]; + if (axis && axis.used) { + key = "y" + axis.n; + if (pos[key] == null && axis.n == 1) + key = "y"; + + if (pos[key] != null) { + res.top = axis.p2c(pos[key]); + break; + } + } + } + + return res; + } + + function getOrCreateAxis(axes, number) { + if (!axes[number - 1]) + axes[number - 1] = { + n: number, // save the number for future reference + direction: axes == xaxes ? "x" : "y", + options: $.extend(true, {}, axes == xaxes ? options.xaxis : options.yaxis) + }; + + return axes[number - 1]; + } + function fillInSeriesOptions() { var i; @@ -300,7 +450,7 @@ // vary color if needed var sign = variation % 2 == 1 ? -1 : 1; - c.scale('rgb', 1 + sign * Math.ceil(variation / 2) * 0.2) + c.scale('rgb', 1 + sign * Math.ceil(variation / 2) * 0.2); // FIXME: if we're getting to close to something else, // we should probably skip this one @@ -330,7 +480,7 @@ if (s.lines.show == null) { var v, show = true; for (v in s) - if (s[v].show) { + if (s[v] && s[v].show) { show = false; break; } @@ -339,30 +489,32 @@ } // setup axes - s.xaxis = axisSpecToRealAxis(s, "xaxis"); - s.yaxis = axisSpecToRealAxis(s, "yaxis"); + s.xaxis = getOrCreateAxis(xaxes, axisNumber(s, "x")); + s.yaxis = getOrCreateAxis(yaxes, axisNumber(s, "y")); } } function processData() { var topSentry = Number.POSITIVE_INFINITY, bottomSentry = Number.NEGATIVE_INFINITY, + fakeInfinity = Number.MAX_VALUE, i, j, k, m, length, s, points, ps, x, y, axis, val, f, p; - for (axis in axes) { - axes[axis].datamin = topSentry; - axes[axis].datamax = bottomSentry; - axes[axis].used = false; - } - function updateAxis(axis, min, max) { - if (min < axis.datamin) + if (min < axis.datamin && min != -fakeInfinity) axis.datamin = min; - if (max > axis.datamax) + if (max > axis.datamax && max != fakeInfinity) axis.datamax = max; } + $.each(allAxes(), function (_, axis) { + // init axis + axis.datamin = topSentry; + axis.datamax = bottomSentry; + axis.used = false; + }); + for (i = 0; i < series.length; ++i) { s = series[i]; s.datapoints = { points: [] }; @@ -382,8 +534,13 @@ format.push({ x: true, number: true, required: true }); format.push({ y: true, number: true, required: true }); - if (s.bars.show) + if (s.bars.show || (s.lines.show && s.lines.fill)) { format.push({ y: true, number: true, required: false, defaultValue: 0 }); + if (s.bars.horizontal) { + delete format[format.length - 1].y; + format[format.length - 1].x = true; + } + } s.datapoints.format = format; } @@ -391,8 +548,7 @@ if (s.datapoints.pointsize != null) continue; // already filled in - if (s.datapoints.pointsize == null) - s.datapoints.pointsize = format.length; + s.datapoints.pointsize = format.length; ps = s.datapoints.pointsize; points = s.datapoints.points; @@ -414,6 +570,10 @@ val = +val; // convert to number if (isNaN(val)) val = null; + else if (val == Infinity) + val = fakeInfinity; + else if (val == -Infinity) + val = -fakeInfinity; } if (val == null) { @@ -477,6 +637,7 @@ s = series[i]; points = s.datapoints.points, ps = s.datapoints.pointsize; + format = s.datapoints.format; var xmin = topSentry, ymin = topSentry, xmax = bottomSentry, ymax = bottomSentry; @@ -488,7 +649,7 @@ for (m = 0; m < ps; ++m) { val = points[j + m]; f = format[m]; - if (!f) + if (!f || val == fakeInfinity || val == -fakeInfinity) continue; if (f.x) { @@ -505,10 +666,25 @@ } } } - + if (s.bars.show) { // make sure we got room for the bar on the dancing floor - var delta = s.bars.align == "left" ? 0 : -s.bars.barWidth/2; + var delta; + + switch (s.bars.align) { + case "left": + delta = 0; + break; + case "right": + delta = -s.bars.barWidth; + break; + case "center": + delta = -s.bars.barWidth / 2; + break; + default: + throw new Error("Invalid bar alignment: " + s.bars.align); + } + if (s.bars.horizontal) { ymin += delta; ymax += delta + s.bars.barWidth; @@ -523,54 +699,122 @@ updateAxis(s.yaxis, ymin, ymax); } - for (axis in axes) { - if (axes[axis].datamin == topSentry) - axes[axis].datamin = null; - if (axes[axis].datamax == bottomSentry) - axes[axis].datamax = null; - } + $.each(allAxes(), function (_, axis) { + if (axis.datamin == topSentry) + axis.datamin = null; + if (axis.datamax == bottomSentry) + axis.datamax = null; + }); } - function constructCanvas() { - function makeCanvas(width, height) { - var c = document.createElement('canvas'); - c.width = width; - c.height = height; - if ($.browser.msie) // excanvas hack - c = window.G_vmlCanvasManager.initElement(c); - return c; - } + function makeCanvas(skipPositioning, cls) { + var c = document.createElement('canvas'); + c.className = cls; + c.width = canvasWidth; + c.height = canvasHeight; + + if (!skipPositioning) + $(c).css({ position: 'absolute', left: 0, top: 0 }); + + $(c).appendTo(placeholder); + + if (!c.getContext) // excanvas hack + c = window.G_vmlCanvasManager.initElement(c); + + // used for resetting in case we get replotted + c.getContext("2d").save(); + return c; + } + + function getCanvasDimensions() { canvasWidth = placeholder.width(); canvasHeight = placeholder.height(); - placeholder.html(""); // clear placeholder - if (placeholder.css("position") == 'static') - placeholder.css("position", "relative"); // for positioning labels and overlay - - if (canvasWidth <= 0 || canvasHeight <= 0) - throw "Invalid dimensions for plot, width = " + canvasWidth + ", height = " + canvasHeight; - - if ($.browser.msie) // excanvas hack - window.G_vmlCanvasManager.init_(document); // make sure everything is setup - // the canvas - canvas = $(makeCanvas(canvasWidth, canvasHeight)).appendTo(placeholder).get(0); - ctx = canvas.getContext("2d"); + if (canvasWidth <= 0 || canvasHeight <= 0) + throw new Error("Invalid dimensions for plot, width = " + canvasWidth + ", height = " + canvasHeight); + } - // overlay canvas for interactive features - overlay = $(makeCanvas(canvasWidth, canvasHeight)).css({ position: 'absolute', left: 0, top: 0 }).appendTo(placeholder).get(0); + function resizeCanvas(c) { + // resizing should reset the state (excanvas seems to be + // buggy though) + if (c.width != canvasWidth) + c.width = canvasWidth; + + if (c.height != canvasHeight) + c.height = canvasHeight; + + // so try to get back to the initial state (even if it's + // gone now, this should be safe according to the spec) + var cctx = c.getContext("2d"); + cctx.restore(); + + // and save again + cctx.save(); + } + + function setupCanvases() { + var reused, + existingCanvas = placeholder.children("canvas.flot-base"), + existingOverlay = placeholder.children("canvas.flot-overlay"); + + if (existingCanvas.length == 0 || existingOverlay == 0) { + // init everything + + placeholder.html(""); // make sure placeholder is clear + + placeholder.css({ padding: 0 }); // padding messes up the positioning + + if (placeholder.css("position") == 'static') + placeholder.css("position", "relative"); // for positioning labels and overlay + + getCanvasDimensions(); + + canvas = makeCanvas(true, "flot-base"); + overlay = makeCanvas(false, "flot-overlay"); // overlay canvas for interactive features + + reused = false; + } + else { + // reuse existing elements + + canvas = existingCanvas.get(0); + overlay = existingOverlay.get(0); + + reused = true; + } + + ctx = canvas.getContext("2d"); octx = overlay.getContext("2d"); - octx.stroke(); + + // define which element we're listening for events on + eventHolder = $(overlay); + + if (reused) { + // run shutdown in the old plot object + placeholder.data("plot").shutdown(); + + // reset reused canvases + plot.resize(); + + // make sure overlay pixels are cleared (canvas is cleared when we redraw) + octx.clearRect(0, 0, canvasWidth, canvasHeight); + + // then whack any remaining obvious garbage left + eventHolder.unbind(); + placeholder.children().not([canvas, overlay]).remove(); + } + + // save in case we get replotted + placeholder.data("plot", plot); } function bindEvents() { - // we include the canvas in the event holder too, because IE 7 - // sometimes has trouble with the stacking order - eventHolder = $([overlay, canvas]); - // bind events - if (options.grid.hoverable) + if (options.grid.hoverable) { eventHolder.mousemove(onMouseMove); + eventHolder.mouseleave(onMouseLeave); + } if (options.grid.clickable) eventHolder.click(onClick); @@ -578,183 +822,327 @@ executeHooks(hooks.bindEvents, [eventHolder]); } - function setupGrid() { - function setTransformationHelpers(axis, o) { - function identity(x) { return x; } - - var s, m, t = o.transform || identity, - it = o.inverseTransform; - - // add transformation helpers - if (axis == axes.xaxis || axis == axes.x2axis) { - // precompute how much the axis is scaling a point - // in canvas space - s = axis.scale = plotWidth / (t(axis.max) - t(axis.min)); - m = t(axis.min); - - // data point to canvas coordinate - if (t == identity) // slight optimization - axis.p2c = function (p) { return (p - m) * s; }; - else - axis.p2c = function (p) { return (t(p) - m) * s; }; - // canvas coordinate to data point - if (!it) - axis.c2p = function (c) { return m + c / s; }; - else - axis.c2p = function (c) { return it(m + c / s); }; - } - else { - s = axis.scale = plotHeight / (t(axis.max) - t(axis.min)); - m = t(axis.max); - - if (t == identity) - axis.p2c = function (p) { return (m - p) * s; }; - else - axis.p2c = function (p) { return (m - t(p)) * s; }; - if (!it) - axis.c2p = function (c) { return m - c / s; }; - else - axis.c2p = function (c) { return it(m - c / s); }; - } - } - - function measureLabels(axis, axisOptions) { - var i, labels = [], l; - - axis.labelWidth = axisOptions.labelWidth; - axis.labelHeight = axisOptions.labelHeight; - - if (axis == axes.xaxis || axis == axes.x2axis) { - // to avoid measuring the widths of the labels, we - // construct fixed-size boxes and put the labels inside - // them, we don't need the exact figures and the - // fixed-size box content is easy to center - if (axis.labelWidth == null) - axis.labelWidth = canvasWidth / (axis.ticks.length > 0 ? axis.ticks.length : 1); - - // measure x label heights - if (axis.labelHeight == null) { - labels = []; - for (i = 0; i < axis.ticks.length; ++i) { - l = axis.ticks[i].label; - if (l) - labels.push('
' + l + '
'); - } - - if (labels.length > 0) { - var dummyDiv = $('
' - + labels.join("") + '
').appendTo(placeholder); - axis.labelHeight = dummyDiv.height(); - dummyDiv.remove(); - } - } - } - else if (axis.labelWidth == null || axis.labelHeight == null) { - // calculate y label dimensions - for (i = 0; i < axis.ticks.length; ++i) { - l = axis.ticks[i].label; - if (l) - labels.push('
' + l + '
'); - } - - if (labels.length > 0) { - var dummyDiv = $('
' - + labels.join("") + '
').appendTo(placeholder); - if (axis.labelWidth == null) - axis.labelWidth = dummyDiv.width(); - if (axis.labelHeight == null) - axis.labelHeight = dummyDiv.find("div").height(); - dummyDiv.remove(); - } - - } - - if (axis.labelWidth == null) - axis.labelWidth = 0; - if (axis.labelHeight == null) - axis.labelHeight = 0; - } + function shutdown() { + if (redrawTimeout) + clearTimeout(redrawTimeout); - function setGridSpacing() { - // get the most space needed around the grid for things - // that may stick out - var maxOutset = options.grid.borderWidth; - for (i = 0; i < series.length; ++i) - maxOutset = Math.max(maxOutset, 2 * (series[i].points.radius + series[i].points.lineWidth/2)); - - plotOffset.left = plotOffset.right = plotOffset.top = plotOffset.bottom = maxOutset; - - var margin = options.grid.labelMargin + options.grid.borderWidth; - - if (axes.xaxis.labelHeight > 0) - plotOffset.bottom = Math.max(maxOutset, axes.xaxis.labelHeight + margin); - if (axes.yaxis.labelWidth > 0) - plotOffset.left = Math.max(maxOutset, axes.yaxis.labelWidth + margin); - if (axes.x2axis.labelHeight > 0) - plotOffset.top = Math.max(maxOutset, axes.x2axis.labelHeight + margin); - if (axes.y2axis.labelWidth > 0) - plotOffset.right = Math.max(maxOutset, axes.y2axis.labelWidth + margin); + eventHolder.unbind("mousemove", onMouseMove); + eventHolder.unbind("mouseleave", onMouseLeave); + eventHolder.unbind("click", onClick); - plotWidth = canvasWidth - plotOffset.left - plotOffset.right; - plotHeight = canvasHeight - plotOffset.bottom - plotOffset.top; - } - - var axis; - for (axis in axes) - setRange(axes[axis], options[axis]); - - if (options.grid.show) { - for (axis in axes) { - prepareTickGeneration(axes[axis], options[axis]); - setTicks(axes[axis], options[axis]); - measureLabels(axes[axis], options[axis]); - } + executeHooks(hooks.shutdown, [eventHolder]); + } - setGridSpacing(); + function setTransformationHelpers(axis) { + // set helper functions on the axis, assumes plot area + // has been computed already + + function identity(x) { return x; } + + var s, m, t = axis.options.transform || identity, + it = axis.options.inverseTransform; + + // precompute how much the axis is scaling a point + // in canvas space + if (axis.direction == "x") { + s = axis.scale = plotWidth / Math.abs(t(axis.max) - t(axis.min)); + m = Math.min(t(axis.max), t(axis.min)); } else { - plotOffset.left = plotOffset.right = plotOffset.top = plotOffset.bottom = 0; - plotWidth = canvasWidth; - plotHeight = canvasHeight; + s = axis.scale = plotHeight / Math.abs(t(axis.max) - t(axis.min)); + s = -s; + m = Math.max(t(axis.max), t(axis.min)); + } + + // data point to canvas coordinate + if (t == identity) // slight optimization + axis.p2c = function (p) { return (p - m) * s; }; + else + axis.p2c = function (p) { return (t(p) - m) * s; }; + // canvas coordinate to data point + if (!it) + axis.c2p = function (c) { return m + c / s; }; + else + axis.c2p = function (c) { return it(m + c / s); }; + } + + function measureTickLabels(axis) { + var opts = axis.options, ticks = axis.ticks || [], + axisw = opts.labelWidth || 0, axish = opts.labelHeight || 0, + f = axis.font; + + ctx.save(); + ctx.font = f.style + " " + f.variant + " " + f.weight + " " + f.size + "px '" + f.family + "'"; + + for (var i = 0; i < ticks.length; ++i) { + var t = ticks[i]; + + t.lines = []; + t.width = t.height = 0; + + if (!t.label) + continue; + + // accept various kinds of newlines, including HTML ones + // (you can actually split directly on regexps in Javascript, + // but IE is unfortunately broken) + var lines = t.label.replace(/
|\r\n|\r/g, "\n").split("\n"); + for (var j = 0; j < lines.length; ++j) { + var line = { text: lines[j] }, + m = ctx.measureText(line.text); + + line.width = m.width; + // m.height might not be defined, not in the + // standard yet + line.height = m.height != null ? m.height : f.size; + + // add a bit of margin since font rendering is + // not pixel perfect and cut off letters look + // bad, this also doubles as spacing between + // lines + line.height += Math.round(f.size * 0.15); + + t.width = Math.max(line.width, t.width); + t.height += line.height; + + t.lines.push(line); + } + + if (opts.labelWidth == null) + axisw = Math.max(axisw, t.width); + if (opts.labelHeight == null) + axish = Math.max(axish, t.height); + } + ctx.restore(); + + axis.labelWidth = Math.ceil(axisw); + axis.labelHeight = Math.ceil(axish); + } + + function allocateAxisBoxFirstPhase(axis) { + // find the bounding box of the axis by looking at label + // widths/heights and ticks, make room by diminishing the + // plotOffset; this first phase only looks at one + // dimension per axis, the other dimension depends on the + // other axes so will have to wait + + var lw = axis.labelWidth, + lh = axis.labelHeight, + pos = axis.options.position, + tickLength = axis.options.tickLength, + axisMargin = options.grid.axisMargin, + padding = options.grid.labelMargin, + all = axis.direction == "x" ? xaxes : yaxes, + index; + + // determine axis margin + var samePosition = $.grep(all, function (a) { + return a && a.options.position == pos && a.reserveSpace; + }); + if ($.inArray(axis, samePosition) == samePosition.length - 1) + axisMargin = 0; // outermost + + // determine tick length - if we're innermost, we can use "full" + if (tickLength == null) { + var sameDirection = $.grep(all, function (a) { + return a && a.reserveSpace; + }); + + var innermost = $.inArray(axis, sameDirection) == 0; + if (innermost) + tickLength = "full"; + else + tickLength = 5; } - for (axis in axes) - setTransformationHelpers(axes[axis], options[axis]); + if (!isNaN(+tickLength)) + padding += +tickLength; - if (options.grid.show) - insertLabels(); + // compute box + if (axis.direction == "x") { + lh += padding; + + if (pos == "bottom") { + plotOffset.bottom += lh + axisMargin; + axis.box = { top: canvasHeight - plotOffset.bottom, height: lh }; + } + else { + axis.box = { top: plotOffset.top + axisMargin, height: lh }; + plotOffset.top += lh + axisMargin; + } + } + else { + lw += padding; + + if (pos == "left") { + axis.box = { left: plotOffset.left + axisMargin, width: lw }; + plotOffset.left += lw + axisMargin; + } + else { + plotOffset.right += lw + axisMargin; + axis.box = { left: canvasWidth - plotOffset.right, width: lw }; + } + } + + // save for future reference + axis.position = pos; + axis.tickLength = tickLength; + axis.box.padding = padding; + axis.innermost = innermost; + } + + function allocateAxisBoxSecondPhase(axis) { + // now that all axis boxes have been placed in one + // dimension, we can set the remaining dimension coordinates + if (axis.direction == "x") { + axis.box.left = plotOffset.left - axis.labelWidth / 2; + axis.box.width = canvasWidth - plotOffset.left - plotOffset.right + axis.labelWidth; + } + else { + axis.box.top = plotOffset.top - axis.labelHeight / 2; + axis.box.height = canvasHeight - plotOffset.bottom - plotOffset.top + axis.labelHeight; + } + } + + function adjustLayoutForThingsStickingOut() { + // possibly adjust plot offset to ensure everything stays + // inside the canvas and isn't clipped off + + var minMargin = options.grid.minBorderMargin, + margins = { x: 0, y: 0 }, i, axis; + + // check stuff from the plot (FIXME: this should just read + // a value from the series, otherwise it's impossible to + // customize) + if (minMargin == null) { + minMargin = 0; + for (i = 0; i < series.length; ++i) + minMargin = Math.max(minMargin, 2 * (series[i].points.radius + series[i].points.lineWidth/2)); + } + + margins.x = margins.y = Math.ceil(minMargin); + + // check axis labels, note we don't check the actual + // labels but instead use the overall width/height to not + // jump as much around with replots + $.each(allAxes(), function (_, axis) { + var dir = axis.direction; + if (axis.reserveSpace) + margins[dir] = Math.ceil(Math.max(margins[dir], (dir == "x" ? axis.labelWidth : axis.labelHeight) / 2)); + }); + + plotOffset.left = Math.max(margins.x, plotOffset.left); + plotOffset.right = Math.max(margins.x, plotOffset.right); + plotOffset.top = Math.max(margins.y, plotOffset.top); + plotOffset.bottom = Math.max(margins.y, plotOffset.bottom); + } + + function setupGrid() { + var i, axes = allAxes(), showGrid = options.grid.show; + + // Initialize the plot's offset from the edge of the canvas + + for (var a in plotOffset) { + var margin = options.grid.margin || 0; + plotOffset[a] = typeof margin == "number" ? margin : margin[a] || 0; + } + + executeHooks(hooks.processOffset, [plotOffset]); + + // If the grid is visible, add its border width to the offset + + for (var a in plotOffset) + plotOffset[a] += showGrid ? options.grid.borderWidth : 0; + + // init axes + $.each(axes, function (_, axis) { + axis.show = axis.options.show; + if (axis.show == null) + axis.show = axis.used; // by default an axis is visible if it's got data + + axis.reserveSpace = axis.show || axis.options.reserveSpace; + + setRange(axis); + }); + + if (showGrid) { + // determine from the placeholder the font size ~ height of font ~ 1 em + var fontDefaults = { + style: placeholder.css("font-style"), + size: Math.round(0.8 * (+placeholder.css("font-size").replace("px", "") || 13)), + variant: placeholder.css("font-variant"), + weight: placeholder.css("font-weight"), + family: placeholder.css("font-family") + }; + + var allocatedAxes = $.grep(axes, function (axis) { return axis.reserveSpace; }); + + $.each(allocatedAxes, function (_, axis) { + // make the ticks + setupTickGeneration(axis); + setTicks(axis); + snapRangeToTicks(axis, axis.ticks); + + // find labelWidth/Height for axis + axis.font = $.extend({}, fontDefaults, axis.options.font); + measureTickLabels(axis); + }); + + // with all dimensions calculated, we can compute the + // axis bounding boxes, start from the outside + // (reverse order) + for (i = allocatedAxes.length - 1; i >= 0; --i) + allocateAxisBoxFirstPhase(allocatedAxes[i]); + + // make sure we've got enough space for things that + // might stick out + adjustLayoutForThingsStickingOut(); + + $.each(allocatedAxes, function (_, axis) { + allocateAxisBoxSecondPhase(axis); + }); + } + + plotWidth = canvasWidth - plotOffset.left - plotOffset.right; + plotHeight = canvasHeight - plotOffset.bottom - plotOffset.top; + + // now we got the proper plot dimensions, we can compute the scaling + $.each(axes, function (_, axis) { + setTransformationHelpers(axis); + }); insertLegend(); } - function setRange(axis, axisOptions) { - var min = +(axisOptions.min != null ? axisOptions.min : axis.datamin), - max = +(axisOptions.max != null ? axisOptions.max : axis.datamax), + function setRange(axis) { + var opts = axis.options, + min = +(opts.min != null ? opts.min : axis.datamin), + max = +(opts.max != null ? opts.max : axis.datamax), delta = max - min; if (delta == 0.0) { // degenerate case var widen = max == 0 ? 1 : 0.01; - if (axisOptions.min == null) + if (opts.min == null) min -= widen; - // alway widen max if we couldn't widen min to ensure we + // always widen max if we couldn't widen min to ensure we // don't fall into min == max which doesn't work - if (axisOptions.max == null || axisOptions.min != null) + if (opts.max == null || opts.min != null) max += widen; } else { // consider autoscaling - var margin = axisOptions.autoscaleMargin; + var margin = opts.autoscaleMargin; if (margin != null) { - if (axisOptions.min == null) { + if (opts.min == null) { min -= delta * margin; // make sure we don't go below zero if all values // are positive if (min < 0 && axis.datamin != null && axis.datamin >= 0) min = 0; } - if (axisOptions.max == null) { + if (opts.max == null) { max += delta * margin; if (max > 0 && axis.datamax != null && axis.datamax <= 0) max = 0; @@ -765,192 +1153,32 @@ axis.max = max; } - function prepareTickGeneration(axis, axisOptions) { + function setupTickGeneration(axis) { + var opts = axis.options; + // estimate number of ticks var noTicks; - if (typeof axisOptions.ticks == "number" && axisOptions.ticks > 0) - noTicks = axisOptions.ticks; - else if (axis == axes.xaxis || axis == axes.x2axis) - // heuristic based on the model a*sqrt(x) fitted to - // some reasonable data points - noTicks = 0.3 * Math.sqrt(canvasWidth); + if (typeof opts.ticks == "number" && opts.ticks > 0) + noTicks = opts.ticks; else - noTicks = 0.3 * Math.sqrt(canvasHeight); - - var delta = (axis.max - axis.min) / noTicks, - size, generator, unit, formatter, i, magn, norm; + // heuristic based on the model a*sqrt(x) fitted to + // some data points that seemed reasonable + noTicks = 0.3 * Math.sqrt(axis.direction == "x" ? canvasWidth : canvasHeight); - if (axisOptions.mode == "time") { - // pretty handling of time - - // map of app. size of time units in milliseconds - var timeUnitSize = { - "second": 1000, - "minute": 60 * 1000, - "hour": 60 * 60 * 1000, - "day": 24 * 60 * 60 * 1000, - "month": 30 * 24 * 60 * 60 * 1000, - "year": 365.2425 * 24 * 60 * 60 * 1000 - }; + axis.delta = (axis.max - axis.min) / noTicks; - - // the allowed tick sizes, after 1 year we use - // an integer algorithm - var spec = [ - [1, "second"], [2, "second"], [5, "second"], [10, "second"], - [30, "second"], - [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], - [30, "minute"], - [1, "hour"], [2, "hour"], [4, "hour"], - [8, "hour"], [12, "hour"], - [1, "day"], [2, "day"], [3, "day"], - [0.25, "month"], [0.5, "month"], [1, "month"], - [2, "month"], [3, "month"], [6, "month"], - [1, "year"] - ]; - - var minSize = 0; - if (axisOptions.minTickSize != null) { - if (typeof axisOptions.tickSize == "number") - minSize = axisOptions.tickSize; - else - minSize = axisOptions.minTickSize[0] * timeUnitSize[axisOptions.minTickSize[1]]; - } - - for (i = 0; i < spec.length - 1; ++i) - if (delta < (spec[i][0] * timeUnitSize[spec[i][1]] - + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 - && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) - break; - size = spec[i][0]; - unit = spec[i][1]; - - // special-case the possibility of several years - if (unit == "year") { - magn = Math.pow(10, Math.floor(Math.log(delta / timeUnitSize.year) / Math.LN10)); - norm = (delta / timeUnitSize.year) / magn; - if (norm < 1.5) - size = 1; - else if (norm < 3) - size = 2; - else if (norm < 7.5) - size = 5; - else - size = 10; - - size *= magn; - } - - if (axisOptions.tickSize) { - size = axisOptions.tickSize[0]; - unit = axisOptions.tickSize[1]; - } - - generator = function(axis) { - var ticks = [], - tickSize = axis.tickSize[0], unit = axis.tickSize[1], - d = new Date(axis.min); - - var step = tickSize * timeUnitSize[unit]; - - if (unit == "second") - d.setUTCSeconds(floorInBase(d.getUTCSeconds(), tickSize)); - if (unit == "minute") - d.setUTCMinutes(floorInBase(d.getUTCMinutes(), tickSize)); - if (unit == "hour") - d.setUTCHours(floorInBase(d.getUTCHours(), tickSize)); - if (unit == "month") - d.setUTCMonth(floorInBase(d.getUTCMonth(), tickSize)); - if (unit == "year") - d.setUTCFullYear(floorInBase(d.getUTCFullYear(), tickSize)); - - // reset smaller components - d.setUTCMilliseconds(0); - if (step >= timeUnitSize.minute) - d.setUTCSeconds(0); - if (step >= timeUnitSize.hour) - d.setUTCMinutes(0); - if (step >= timeUnitSize.day) - d.setUTCHours(0); - if (step >= timeUnitSize.day * 4) - d.setUTCDate(1); - if (step >= timeUnitSize.year) - d.setUTCMonth(0); - - - var carry = 0, v = Number.NaN, prev; - do { - prev = v; - v = d.getTime(); - ticks.push({ v: v, label: axis.tickFormatter(v, axis) }); - if (unit == "month") { - if (tickSize < 1) { - // a bit complicated - we'll divide the month - // up but we need to take care of fractions - // so we don't end up in the middle of a day - d.setUTCDate(1); - var start = d.getTime(); - d.setUTCMonth(d.getUTCMonth() + 1); - var end = d.getTime(); - d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); - carry = d.getUTCHours(); - d.setUTCHours(0); - } - else - d.setUTCMonth(d.getUTCMonth() + tickSize); - } - else if (unit == "year") { - d.setUTCFullYear(d.getUTCFullYear() + tickSize); - } - else - d.setTime(v + step); - } while (v < axis.max && v != prev); - - return ticks; - }; - - formatter = function (v, axis) { - var d = new Date(v); - - // first check global format - if (axisOptions.timeformat != null) - return $.plot.formatDate(d, axisOptions.timeformat, axisOptions.monthNames); - - var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; - var span = axis.max - axis.min; - var suffix = (axisOptions.twelveHourClock) ? " %p" : ""; - - if (t < timeUnitSize.minute) - fmt = "%h:%M:%S" + suffix; - else if (t < timeUnitSize.day) { - if (span < 2 * timeUnitSize.day) - fmt = "%h:%M" + suffix; - else - fmt = "%b %d %h:%M" + suffix; - } - else if (t < timeUnitSize.month) - fmt = "%b %d"; - else if (t < timeUnitSize.year) { - if (span < timeUnitSize.year) - fmt = "%b"; - else - fmt = "%b %y"; - } - else - fmt = "%y"; - - return $.plot.formatDate(d, fmt, axisOptions.monthNames); - }; - } - else { + // special modes are handled by plug-ins, e.g. "time". The default + // is base-10 numbers. + if (!opts.mode) { // pretty rounding of base-10 numbers - var maxDec = axisOptions.tickDecimals; - var dec = -Math.floor(Math.log(delta) / Math.LN10); + var maxDec = opts.tickDecimals; + var dec = -Math.floor(Math.log(axis.delta) / Math.LN10); if (maxDec != null && dec > maxDec) dec = maxDec; - magn = Math.pow(10, -dec); - norm = delta / magn; // norm is between 1.0 and 10.0 + var magn = Math.pow(10, -dec); + var norm = axis.delta / magn; // norm is between 1.0 and 10.0 + var size; if (norm < 1.5) size = 1; @@ -969,15 +1197,13 @@ size *= magn; - if (axisOptions.minTickSize != null && size < axisOptions.minTickSize) - size = axisOptions.minTickSize; + if (opts.minTickSize != null && size < opts.minTickSize) + size = opts.minTickSize; - if (axisOptions.tickSize != null) - size = axisOptions.tickSize; + axis.tickDecimals = Math.max(0, maxDec != null ? maxDec : dec); + axis.tickSize = opts.tickSize || size; - axis.tickDecimals = Math.max(0, (maxDec != null) ? maxDec : dec); - - generator = function (axis) { + axis.tickGenerator = function (axis) { var ticks = []; // spew out all possible ticks @@ -986,135 +1212,193 @@ do { prev = v; v = start + i * axis.tickSize; - ticks.push({ v: v, label: axis.tickFormatter(v, axis) }); + ticks.push(v); ++i; } while (v < axis.max && v != prev); return ticks; }; - formatter = function (v, axis) { + axis.tickFormatter = function (v, axis) { return v.toFixed(axis.tickDecimals); }; } - axis.tickSize = unit ? [size, unit] : size; - axis.tickGenerator = generator; - if ($.isFunction(axisOptions.tickFormatter)) - axis.tickFormatter = function (v, axis) { return "" + axisOptions.tickFormatter(v, axis); }; - else - axis.tickFormatter = formatter; - } - - function setTicks(axis, axisOptions) { - axis.ticks = []; + if ($.isFunction(opts.tickFormatter)) + axis.tickFormatter = function (v, axis) { return "" + opts.tickFormatter(v, axis); }; - if (!axis.used) - return; - - if (axisOptions.ticks == null) - axis.ticks = axis.tickGenerator(axis); - else if (typeof axisOptions.ticks == "number") { - if (axisOptions.ticks > 0) - axis.ticks = axis.tickGenerator(axis); - } - else if (axisOptions.ticks) { - var ticks = axisOptions.ticks; - - if ($.isFunction(ticks)) - // generate the ticks - ticks = ticks({ min: axis.min, max: axis.max }); - - // clean up the user-supplied ticks, copy them over - var i, v; - for (i = 0; i < ticks.length; ++i) { - var label = null; - var t = ticks[i]; - if (typeof t == "object") { - v = t[0]; - if (t.length > 1) - label = t[1]; + if (opts.alignTicksWithAxis != null) { + var otherAxis = (axis.direction == "x" ? xaxes : yaxes)[opts.alignTicksWithAxis - 1]; + if (otherAxis && otherAxis.used && otherAxis != axis) { + // consider snapping min/max to outermost nice ticks + var niceTicks = axis.tickGenerator(axis); + if (niceTicks.length > 0) { + if (opts.min == null) + axis.min = Math.min(axis.min, niceTicks[0]); + if (opts.max == null && niceTicks.length > 1) + axis.max = Math.max(axis.max, niceTicks[niceTicks.length - 1]); + } + + axis.tickGenerator = function (axis) { + // copy ticks, scaled to this axis + var ticks = [], v, i; + for (i = 0; i < otherAxis.ticks.length; ++i) { + v = (otherAxis.ticks[i].v - otherAxis.min) / (otherAxis.max - otherAxis.min); + v = axis.min + v * (axis.max - axis.min); + ticks.push(v); + } + return ticks; + }; + + // we might need an extra decimal since forced + // ticks don't necessarily fit naturally + if (!axis.mode && opts.tickDecimals == null) { + var extraDec = Math.max(0, -Math.floor(Math.log(axis.delta) / Math.LN10) + 1), + ts = axis.tickGenerator(axis); + + // only proceed if the tick interval rounded + // with an extra decimal doesn't give us a + // zero at end + if (!(ts.length > 1 && /\..*0$/.test((ts[1] - ts[0]).toFixed(extraDec)))) + axis.tickDecimals = extraDec; } - else - v = t; - if (label == null) - label = axis.tickFormatter(v, axis); - axis.ticks[i] = { v: v, label: label }; } } + } + + function setTicks(axis) { + var oticks = axis.options.ticks, ticks = []; + if (oticks == null || (typeof oticks == "number" && oticks > 0)) + ticks = axis.tickGenerator(axis); + else if (oticks) { + if ($.isFunction(oticks)) + // generate the ticks + ticks = oticks(axis); + else + ticks = oticks; + } - if (axisOptions.autoscaleMargin != null && axis.ticks.length > 0) { + // clean up/labelify the supplied ticks, copy them over + var i, v; + axis.ticks = []; + for (i = 0; i < ticks.length; ++i) { + var label = null; + var t = ticks[i]; + if (typeof t == "object") { + v = +t[0]; + if (t.length > 1) + label = t[1]; + } + else + v = +t; + if (label == null) + label = axis.tickFormatter(v, axis); + if (!isNaN(v)) + axis.ticks.push({ v: v, label: label }); + } + } + + function snapRangeToTicks(axis, ticks) { + if (axis.options.autoscaleMargin && ticks.length > 0) { // snap to ticks - if (axisOptions.min == null) - axis.min = Math.min(axis.min, axis.ticks[0].v); - if (axisOptions.max == null && axis.ticks.length > 1) - axis.max = Math.max(axis.max, axis.ticks[axis.ticks.length - 1].v); + if (axis.options.min == null) + axis.min = Math.min(axis.min, ticks[0].v); + if (axis.options.max == null && ticks.length > 1) + axis.max = Math.max(axis.max, ticks[ticks.length - 1].v); } } function draw() { ctx.clearRect(0, 0, canvasWidth, canvasHeight); - var grid = options.grid; - - if (grid.show && !grid.aboveData) - drawGrid(); + executeHooks(hooks.drawBackground, [ctx]); - for (var i = 0; i < series.length; ++i) + var grid = options.grid; + + // draw background, if any + if (grid.show && grid.backgroundColor) + drawBackground(); + + if (grid.show && !grid.aboveData) { + drawGrid(); + drawAxisLabels(); + } + + for (var i = 0; i < series.length; ++i) { + executeHooks(hooks.drawSeries, [ctx, series[i]]); drawSeries(series[i]); + } executeHooks(hooks.draw, [ctx]); - if (grid.show && grid.aboveData) + if (grid.show && grid.aboveData) { drawGrid(); + drawAxisLabels(); + } } function extractRange(ranges, coord) { - var firstAxis = coord + "axis", - secondaryAxis = coord + "2axis", - axis, from, to, reverse; + var axis, from, to, key, axes = allAxes(); - if (ranges[firstAxis]) { - axis = axes[firstAxis]; - from = ranges[firstAxis].from; - to = ranges[firstAxis].to; + for (var i = 0; i < axes.length; ++i) { + axis = axes[i]; + if (axis.direction == coord) { + key = coord + axis.n + "axis"; + if (!ranges[key] && axis.n == 1) + key = coord + "axis"; // support x1axis as xaxis + if (ranges[key]) { + from = ranges[key].from; + to = ranges[key].to; + break; + } + } } - else if (ranges[secondaryAxis]) { - axis = axes[secondaryAxis]; - from = ranges[secondaryAxis].from; - to = ranges[secondaryAxis].to; - } - else { - // backwards-compat stuff - to be removed in future - axis = axes[firstAxis]; + + // backwards-compat stuff - to be removed in future + if (!ranges[key]) { + axis = coord == "x" ? xaxes[0] : yaxes[0]; from = ranges[coord + "1"]; to = ranges[coord + "2"]; } // auto-reverse as an added bonus - if (from != null && to != null && from > to) - return { from: to, to: from, axis: axis }; + if (from != null && to != null && from > to) { + var tmp = from; + from = to; + to = tmp; + } return { from: from, to: to, axis: axis }; } + function drawBackground() { + ctx.save(); + ctx.translate(plotOffset.left, plotOffset.top); + + ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)"); + ctx.fillRect(0, 0, plotWidth, plotHeight); + ctx.restore(); + } + function drawGrid() { var i; ctx.save(); ctx.translate(plotOffset.left, plotOffset.top); - // draw background, if any - if (options.grid.backgroundColor) { - ctx.fillStyle = getColorOrGradient(options.grid.backgroundColor, plotHeight, 0, "rgba(255, 255, 255, 0)"); - ctx.fillRect(0, 0, plotWidth, plotHeight); - } - // draw markings var markings = options.grid.markings; if (markings) { - if ($.isFunction(markings)) - // xmin etc. are backwards-compatible, to be removed in future - markings = markings({ xmin: axes.xaxis.min, xmax: axes.xaxis.max, ymin: axes.yaxis.min, ymax: axes.yaxis.max, xaxis: axes.xaxis, yaxis: axes.yaxis, x2axis: axes.x2axis, y2axis: axes.y2axis }); + if ($.isFunction(markings)) { + var axes = plot.getAxes(); + // xmin etc. is backwards compatibility, to be + // removed in the future + axes.xmin = axes.xaxis.min; + axes.xmax = axes.xaxis.max; + axes.ymin = axes.yaxis.min; + axes.ymax = axes.yaxis.max; + + markings = markings(axes); + } for (i = 0; i < markings.length; ++i) { var m = markings[i], @@ -1155,8 +1439,6 @@ ctx.beginPath(); ctx.strokeStyle = m.color || options.grid.markingsColor; ctx.lineWidth = m.lineWidth || options.grid.markingsLineWidth; - //ctx.moveTo(Math.floor(xrange.from), yrange.from); - //ctx.lineTo(Math.floor(xrange.to), yrange.to); ctx.moveTo(xrange.from, yrange.from); ctx.lineTo(xrange.to, yrange.to); ctx.stroke(); @@ -1171,55 +1453,98 @@ } } - // draw the inner grid - ctx.lineWidth = 1; - ctx.strokeStyle = options.grid.tickColor; - ctx.beginPath(); - var v, axis = axes.xaxis; - for (i = 0; i < axis.ticks.length; ++i) { - v = axis.ticks[i].v; - if (v <= axis.min || v >= axes.xaxis.max) - continue; // skip those lying on the axes + // draw the ticks + var axes = allAxes(), bw = options.grid.borderWidth; - ctx.moveTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, 0); - ctx.lineTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, plotHeight); - } - - axis = axes.yaxis; - for (i = 0; i < axis.ticks.length; ++i) { - v = axis.ticks[i].v; - if (v <= axis.min || v >= axis.max) + for (var j = 0; j < axes.length; ++j) { + var axis = axes[j], box = axis.box, + t = axis.tickLength, x, y, xoff, yoff; + if (!axis.show || axis.ticks.length == 0) continue; + + ctx.strokeStyle = axis.options.tickColor || $.color.parse(axis.options.color).scale('a', 0.22).toString(); + ctx.lineWidth = 1; - ctx.moveTo(0, Math.floor(axis.p2c(v)) + ctx.lineWidth/2); - ctx.lineTo(plotWidth, Math.floor(axis.p2c(v)) + ctx.lineWidth/2); - } + // find the edges + if (axis.direction == "x") { + x = 0; + if (t == "full") + y = (axis.position == "top" ? 0 : plotHeight); + else + y = box.top - plotOffset.top + (axis.position == "top" ? box.height : 0); + } + else { + y = 0; + if (t == "full") + x = (axis.position == "left" ? 0 : plotWidth); + else + x = box.left - plotOffset.left + (axis.position == "left" ? box.width : 0); + } + + // draw tick bar + if (!axis.innermost) { + ctx.beginPath(); + xoff = yoff = 0; + if (axis.direction == "x") + xoff = plotWidth; + else + yoff = plotHeight; + + if (ctx.lineWidth == 1) { + x = Math.floor(x) + 0.5; + y = Math.floor(y) + 0.5; + } - axis = axes.x2axis; - for (i = 0; i < axis.ticks.length; ++i) { - v = axis.ticks[i].v; - if (v <= axis.min || v >= axis.max) - continue; - - ctx.moveTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, -5); - ctx.lineTo(Math.floor(axis.p2c(v)) + ctx.lineWidth/2, 5); - } + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + ctx.stroke(); + } - axis = axes.y2axis; - for (i = 0; i < axis.ticks.length; ++i) { - v = axis.ticks[i].v; - if (v <= axis.min || v >= axis.max) - continue; + // draw ticks + ctx.beginPath(); + for (i = 0; i < axis.ticks.length; ++i) { + var v = axis.ticks[i].v; + + xoff = yoff = 0; - ctx.moveTo(plotWidth-5, Math.floor(axis.p2c(v)) + ctx.lineWidth/2); - ctx.lineTo(plotWidth+5, Math.floor(axis.p2c(v)) + ctx.lineWidth/2); + if (v < axis.min || v > axis.max + // skip those lying on the axes if we got a border + || (t == "full" && bw > 0 + && (v == axis.min || v == axis.max))) + continue; + + if (axis.direction == "x") { + x = axis.p2c(v); + yoff = t == "full" ? -plotHeight : t; + + if (axis.position == "top") + yoff = -yoff; + } + else { + y = axis.p2c(v); + xoff = t == "full" ? -plotWidth : t; + + if (axis.position == "left") + xoff = -xoff; + } + + if (ctx.lineWidth == 1) { + if (axis.direction == "x") + x = Math.floor(x) + 0.5; + else + y = Math.floor(y) + 0.5; + } + + ctx.moveTo(x, y); + ctx.lineTo(x + xoff, y + yoff); + } + + ctx.stroke(); } - ctx.stroke(); - if (options.grid.borderWidth) { - // draw border - var bw = options.grid.borderWidth; + // draw border + if (bw) { ctx.lineWidth = bw; ctx.strokeStyle = options.grid.borderColor; ctx.strokeRect(-bw/2, -bw/2, plotWidth + bw, plotHeight + bw); @@ -1228,42 +1553,73 @@ ctx.restore(); } - function insertLabels() { - placeholder.find(".tickLabels").remove(); - - var html = ['
']; + function drawAxisLabels() { + ctx.save(); - function addLabels(axis, labelGenerator) { + $.each(allAxes(), function (_, axis) { + if (!axis.show || axis.ticks.length == 0) + return; + + var box = axis.box, f = axis.font; + // placeholder.append('
') // debug + + ctx.fillStyle = axis.options.color; + // Important: Don't use quotes around axis.font.family! Just around single + // font names like 'Times New Roman' that have a space or special character in it. + ctx.font = f.style + " " + f.variant + " " + f.weight + " " + f.size + "px " + f.family; + ctx.textAlign = "start"; + // middle align the labels - top would be more + // natural, but browsers can differ a pixel or two in + // where they consider the top to be, so instead we + // middle align to minimize variation between browsers + // and compensate when calculating the coordinates + ctx.textBaseline = "middle"; + for (var i = 0; i < axis.ticks.length; ++i) { var tick = axis.ticks[i]; if (!tick.label || tick.v < axis.min || tick.v > axis.max) continue; - html.push(labelGenerator(tick, axis)); + + var x, y, offset = 0, line; + for (var k = 0; k < tick.lines.length; ++k) { + line = tick.lines[k]; + + if (axis.direction == "x") { + x = plotOffset.left + axis.p2c(tick.v) - line.width/2; + if (axis.position == "bottom") + y = box.top + box.padding; + else + y = box.top + box.height - box.padding - tick.height; + } + else { + y = plotOffset.top + axis.p2c(tick.v) - tick.height/2; + if (axis.position == "left") + x = box.left + box.width - box.padding - line.width; + else + x = box.left + box.padding; + } + + // account for middle aligning and line number + y += line.height/2 + offset; + offset += line.height; + + if ($.browser.opera) { + // FIXME: UGLY BROWSER DETECTION + // round the coordinates since Opera + // otherwise switches to more ugly + // rendering (probably non-hinted) and + // offset the y coordinates since it seems + // to be off pretty consistently compared + // to the other browsers + x = Math.floor(x); + y = Math.ceil(y - 2); + } + ctx.fillText(line.text, x, y); + } } - } - - var margin = options.grid.labelMargin + options.grid.borderWidth; - - addLabels(axes.xaxis, function (tick, axis) { - return '
' + tick.label + "
"; - }); - - - addLabels(axes.yaxis, function (tick, axis) { - return '
' + tick.label + "
"; - }); - - addLabels(axes.x2axis, function (tick, axis) { - return '
' + tick.label + "
"; - }); - - addLabels(axes.y2axis, function (tick, axis) { - return '
' + tick.label + "
"; }); - html.push('
'); - - placeholder.append(html.join("")); + ctx.restore(); } function drawSeries(series) { @@ -1360,18 +1716,40 @@ var points = datapoints.points, ps = datapoints.pointsize, bottom = Math.min(Math.max(0, axisy.min), axisy.max), - top, lastX = 0, areaOpen = false; - - for (var i = ps; i < points.length; i += ps) { - var x1 = points[i - ps], y1 = points[i - ps + 1], - x2 = points[i], y2 = points[i + 1]; - - if (areaOpen && x1 != null && x2 == null) { - // close area - ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom)); - ctx.fill(); - areaOpen = false; - continue; + i = 0, top, areaOpen = false, + ypos = 1, segmentStart = 0, segmentEnd = 0; + + // we process each segment in two turns, first forward + // direction to sketch out top, then once we hit the + // end we go backwards to sketch the bottom + while (true) { + if (ps > 0 && i > points.length + ps) + break; + + i += ps; // ps is negative if going backwards + + var x1 = points[i - ps], + y1 = points[i - ps + ypos], + x2 = points[i], y2 = points[i + ypos]; + + if (areaOpen) { + if (ps > 0 && x1 != null && x2 == null) { + // at turning point + segmentEnd = i; + ps = -ps; + ypos = 2; + continue; + } + + if (ps < 0 && i == segmentStart + ps) { + // done with the reverse sweep + ctx.fill(); + areaOpen = false; + ps = -ps; + ypos = 1; + i = segmentStart = segmentEnd + ps; + continue; + } } if (x1 == null || x2 == null) @@ -1418,22 +1796,22 @@ if (y1 >= axisy.max && y2 >= axisy.max) { ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.max)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.max)); - lastX = x2; continue; } else if (y1 <= axisy.min && y2 <= axisy.min) { ctx.lineTo(axisx.p2c(x1), axisy.p2c(axisy.min)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(axisy.min)); - lastX = x2; continue; } // else it's a bit more complicated, there might - // be two rectangles and two triangles we need to fill - // in; to find these keep track of the current x values + // be a flat maxed out rectangle first, then a + // triangular cutout or reverse; to find these + // keep track of the current x values var x1old = x1, x2old = x2; - // and clip the y values, without shortcutting + // clip the y values, without shortcutting, we + // go through all cases in turn // clip with ymin if (y1 <= y2 && y1 < axisy.min && y2 >= axisy.min) { @@ -1455,43 +1833,27 @@ y2 = axisy.max; } - // if the x value was changed we got a rectangle // to fill if (x1 != x1old) { - if (y1 <= axisy.min) - top = axisy.min; - else - top = axisy.max; - - ctx.lineTo(axisx.p2c(x1old), axisy.p2c(top)); - ctx.lineTo(axisx.p2c(x1), axisy.p2c(top)); + ctx.lineTo(axisx.p2c(x1old), axisy.p2c(y1)); + // it goes to (x1, y1), but we fill that below } - // fill the triangles + // fill triangular section, this sometimes result + // in redundant points if (x1, y1) hasn't changed + // from previous line to, but we just ignore that ctx.lineTo(axisx.p2c(x1), axisy.p2c(y1)); ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); // fill the other rectangle if it's there if (x2 != x2old) { - if (y2 <= axisy.min) - top = axisy.min; - else - top = axisy.max; - - ctx.lineTo(axisx.p2c(x2), axisy.p2c(top)); - ctx.lineTo(axisx.p2c(x2old), axisy.p2c(top)); + ctx.lineTo(axisx.p2c(x2), axisy.p2c(y2)); + ctx.lineTo(axisx.p2c(x2old), axisy.p2c(y2)); } - - lastX = Math.max(x2, x2old); - } - - if (areaOpen) { - ctx.lineTo(axisx.p2c(lastX), axisy.p2c(bottom)); - ctx.fill(); } } - + ctx.save(); ctx.translate(plotOffset.left, plotOffset.top); ctx.lineJoin = "round"; @@ -1524,16 +1886,23 @@ } function drawSeriesPoints(series) { - function plotPoints(datapoints, radius, fillStyle, offset, circumference, axisx, axisy) { + function plotPoints(datapoints, radius, fillStyle, offset, shadow, axisx, axisy, symbol) { var points = datapoints.points, ps = datapoints.pointsize; - + for (var i = 0; i < points.length; i += ps) { var x = points[i], y = points[i + 1]; if (x == null || x < axisx.min || x > axisx.max || y < axisy.min || y > axisy.max) continue; ctx.beginPath(); - ctx.arc(axisx.p2c(x), axisy.p2c(y) + offset, radius, 0, circumference, false); + x = axisx.p2c(x); + y = axisy.p2c(y) + offset; + if (symbol == "circle") + ctx.arc(x, y, radius, 0, shadow ? Math.PI : Math.PI * 2, false); + else + symbol(ctx, x, y, radius, shadow); + ctx.closePath(); + if (fillStyle) { ctx.fillStyle = fillStyle; ctx.fill(); @@ -1545,35 +1914,39 @@ ctx.save(); ctx.translate(plotOffset.left, plotOffset.top); - var lw = series.lines.lineWidth, + var lw = series.points.lineWidth, sw = series.shadowSize, - radius = series.points.radius; + radius = series.points.radius, + symbol = series.points.symbol; if (lw > 0 && sw > 0) { // draw shadow in two steps var w = sw / 2; ctx.lineWidth = w; ctx.strokeStyle = "rgba(0,0,0,0.1)"; - plotPoints(series.datapoints, radius, null, w + w/2, Math.PI, - series.xaxis, series.yaxis); + plotPoints(series.datapoints, radius, null, w + w/2, true, + series.xaxis, series.yaxis, symbol); ctx.strokeStyle = "rgba(0,0,0,0.2)"; - plotPoints(series.datapoints, radius, null, w/2, Math.PI, - series.xaxis, series.yaxis); + plotPoints(series.datapoints, radius, null, w/2, true, + series.xaxis, series.yaxis, symbol); } ctx.lineWidth = lw; ctx.strokeStyle = series.color; plotPoints(series.datapoints, radius, - getFillStyle(series.points, series.color), 0, 2 * Math.PI, - series.xaxis, series.yaxis); + getFillStyle(series.points, series.color), 0, false, + series.xaxis, series.yaxis, symbol); ctx.restore(); } - function drawBar(x, y, b, barLeft, barRight, offset, fillStyleCallback, axisx, axisy, c, horizontal) { + function drawBar(x, y, b, barLeft, barRight, offset, fillStyleCallback, axisx, axisy, c, horizontal, lineWidth) { var left, right, bottom, top, drawLeft, drawRight, drawTop, drawBottom, tmp; + // in horizontal mode, we start the bar from the left + // instead of from the bottom so it appears to be + // horizontal rather than vertical if (horizontal) { drawBottom = drawRight = drawTop = true; drawLeft = false; @@ -1651,7 +2024,7 @@ } // draw outline - if (drawLeft || drawRight || drawTop || drawBottom) { + if (lineWidth > 0 && (drawLeft || drawRight || drawTop || drawBottom)) { c.beginPath(); // FIXME: inline moveTo is buggy with excanvas @@ -1683,7 +2056,7 @@ for (var i = 0; i < points.length; i += ps) { if (points[i] == null) continue; - drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, offset, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal); + drawBar(points[i], points[i + 1], points[i + 2], barLeft, barRight, offset, fillStyleCallback, axisx, axisy, ctx, series.bars.horizontal, series.bars.lineWidth); } } @@ -1693,7 +2066,23 @@ // FIXME: figure out a way to add shadows (for instance along the right edge) ctx.lineWidth = series.bars.lineWidth; ctx.strokeStyle = series.color; - var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2; + + var barLeft; + + switch (series.bars.align) { + case "left": + barLeft = 0; + break; + case "right": + barLeft = -series.bars.barWidth; + break; + case "center": + barLeft = -series.bars.barWidth / 2; + break; + default: + throw new Error("Invalid bar alignment: " + series.bars.align); + } + var fillStyleCallback = series.bars.fill ? function (bottom, top) { return getFillStyle(series.bars, series.color, bottom, top); } : null; plotBars(series.datapoints, barLeft, barLeft + series.bars.barWidth, 0, fillStyleCallback, series.xaxis, series.yaxis); ctx.restore(); @@ -1721,7 +2110,7 @@ var fragments = [], rowStarted = false, lf = options.legend.labelFormatter, s, label; - for (i = 0; i < series.length; ++i) { + for (var i = 0; i < series.length; ++i) { s = series[i]; label = s.label; if (!label) @@ -1797,7 +2186,7 @@ smallestDistance = maxDistance * maxDistance + 1, item = null, foundPoint = false, i, j; - for (i = 0; i < series.length; ++i) { + for (i = series.length - 1; i >= 0; --i) { if (!seriesFilter(series[i])) continue; @@ -1811,6 +2200,13 @@ maxx = maxDistance / axisx.scale, maxy = maxDistance / axisy.scale; + // with inverse transforms, we can't use the maxx/maxy + // optimization, sadly + if (axisx.options.inverseTransform) + maxx = Number.MAX_VALUE; + if (axisy.options.inverseTransform) + maxy = Number.MAX_VALUE; + if (s.lines.show || s.points.show) { for (j = 0; j < points.length; j += ps) { var x = points[j], y = points[j + 1]; @@ -1831,7 +2227,7 @@ // use <= to ensure last point takes precedence // (last generally means on top of) - if (dist <= smallestDistance) { + if (dist < smallestDistance) { smallestDistance = dist; item = [i, j / ps]; } @@ -1877,7 +2273,13 @@ triggerClickHoverEvent("plothover", e, function (s) { return s["hoverable"] != false; }); } - + + function onMouseLeave(e) { + if (options.grid.hoverable) + triggerClickHoverEvent("plothover", e, + function (s) { return false; }); + } + function onClick(e) { triggerClickHoverEvent("plotclick", e, function (s) { return s["clickable"] != false; }); @@ -1887,18 +2289,12 @@ // so we share their code) function triggerClickHoverEvent(eventname, event, seriesFilter) { var offset = eventHolder.offset(), - pos = { pageX: event.pageX, pageY: event.pageY }, canvasX = event.pageX - offset.left - plotOffset.left, - canvasY = event.pageY - offset.top - plotOffset.top; + canvasY = event.pageY - offset.top - plotOffset.top, + pos = canvasToAxisCoords({ left: canvasX, top: canvasY }); - if (axes.xaxis.used) - pos.x = axes.xaxis.c2p(canvasX); - if (axes.yaxis.used) - pos.y = axes.yaxis.c2p(canvasY); - if (axes.x2axis.used) - pos.x2 = axes.x2axis.c2p(canvasX); - if (axes.y2axis.used) - pos.y2 = axes.y2axis.c2p(canvasY); + pos.pageX = event.pageX; + pos.pageY = event.pageY; var item = findNearbyItem(canvasX, canvasY, seriesFilter); @@ -1913,7 +2309,9 @@ for (var i = 0; i < highlights.length; ++i) { var h = highlights[i]; if (h.auto == eventname && - !(item && h.series == item.series && h.point == item.datapoint)) + !(item && h.series == item.series && + h.point[0] == item.datapoint[0] && + h.point[1] == item.datapoint[1])) unhighlight(h.series, h.point); } @@ -1925,8 +2323,14 @@ } function triggerRedrawOverlay() { + var t = options.interaction.redrawOverlayInterval; + if (t == -1) { // skip event queue + drawOverlay(); + return; + } + if (!redrawTimeout) - redrawTimeout = setTimeout(drawOverlay, 30); + redrawTimeout = setTimeout(drawOverlay, t); } function drawOverlay() { @@ -1955,8 +2359,10 @@ if (typeof s == "number") s = series[s]; - if (typeof point == "number") - point = s.data[point]; + if (typeof point == "number") { + var ps = s.datapoints.pointsize; + point = s.datapoints.points.slice(ps * point, ps * (point + 1)); + } var i = indexOfHighlight(s, point); if (i == -1) { @@ -2008,9 +2414,16 @@ var pointRadius = series.points.radius + series.points.lineWidth / 2; octx.lineWidth = pointRadius; octx.strokeStyle = $.color.parse(series.color).scale('a', 0.5).toString(); - var radius = 1.5 * pointRadius; + var radius = 1.5 * pointRadius, + x = axisx.p2c(x), + y = axisy.p2c(y); + octx.beginPath(); - octx.arc(axisx.p2c(x), axisy.p2c(y), radius, 0, 2 * Math.PI, false); + if (series.points.symbol == "circle") + octx.arc(x, y, radius, 0, 2 * Math.PI, false); + else + series.points.symbol(octx, x, y, radius, false); + octx.closePath(); octx.stroke(); } @@ -2020,7 +2433,7 @@ var fillStyle = $.color.parse(series.color).scale('a', 0.5).toString(); var barLeft = series.bars.align == "left" ? 0 : -series.bars.barWidth/2; drawBar(point[0], point[1], point[2] || 0, barLeft, barLeft + series.bars.barWidth, - 0, function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal); + 0, function () { return fillStyle; }, series.xaxis, series.yaxis, octx, series.bars.horizontal, series.bars.lineWidth); } function getColorOrGradient(spec, bottom, top, defaultColor) { @@ -2035,9 +2448,12 @@ for (var i = 0, l = spec.colors.length; i < l; ++i) { var c = spec.colors[i]; if (typeof c != "string") { - c = $.color.parse(defaultColor).scale('rgb', c.brightness); - c.a *= c.opacity; - c = c.toString(); + var co = $.color.parse(defaultColor); + if (c.brightness != null) + co = co.scale('rgb', c.brightness); + if (c.opacity != null) + co.a *= c.opacity; + c = co.toString(); } gradient.addColorStop(i / (l - 1), c); } @@ -2048,69 +2464,16 @@ } $.plot = function(placeholder, data, options) { + //var t0 = new Date(); var plot = new Plot($(placeholder), data, options, $.plot.plugins); - /*var t0 = new Date(); - var t1 = new Date(); - var tstr = "time used (msecs): " + (t1.getTime() - t0.getTime()) - if (window.console) - console.log(tstr); - else - alert(tstr);*/ + //(window.console ? console.log : alert)("time used (msecs): " + ((new Date()).getTime() - t0.getTime())); return plot; }; + $.plot.version = "0.7"; + $.plot.plugins = []; - // returns a string with the date d formatted according to fmt - $.plot.formatDate = function(d, fmt, monthNames) { - var leftPad = function(n) { - n = "" + n; - return n.length == 1 ? "0" + n : n; - }; - - var r = []; - var escape = false; - var hours = d.getUTCHours(); - var isAM = hours < 12; - if (monthNames == null) - monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - - if (fmt.search(/%p|%P/) != -1) { - if (hours > 12) { - hours = hours - 12; - } else if (hours == 0) { - hours = 12; - } - } - for (var i = 0; i < fmt.length; ++i) { - var c = fmt.charAt(i); - - if (escape) { - switch (c) { - case 'h': c = "" + hours; break; - case 'H': c = leftPad(hours); break; - case 'M': c = leftPad(d.getUTCMinutes()); break; - case 'S': c = leftPad(d.getUTCSeconds()); break; - case 'd': c = "" + d.getUTCDate(); break; - case 'm': c = "" + (d.getUTCMonth() + 1); break; - case 'y': c = "" + d.getUTCFullYear(); break; - case 'b': c = "" + monthNames[d.getUTCMonth()]; break; - case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break; - case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break; - } - r.push(c); - escape = false; - } - else { - if (c == "%") - escape = true; - else - r.push(c); - } - } - return r.join(""); - }; - // round to nearby lower multiple of base function floorInBase(n, base) { return base * Math.floor(n / base); diff --git a/r2/r2/public/static/js/lib/jquery.flot.time.js b/r2/r2/public/static/js/lib/jquery.flot.time.js new file mode 100644 index 000000000..dfe724428 --- /dev/null +++ b/r2/r2/public/static/js/lib/jquery.flot.time.js @@ -0,0 +1,308 @@ +/* +Pretty handling of time axes. + +Set axis.mode to "time" to enable. See the section "Time series data" in API.txt +for details. +*/ +(function ($) { + var options = {}; + + // round to nearby lower multiple of base + function floorInBase(n, base) { + return base * Math.floor(n / base); + } + + // Returns a string with the date d formatted according to fmt. + // A subset of the Open Group's strftime format is supported. + function formatDate(d, fmt, monthNames, dayNames) { + if (typeof d.strftime == "function") { + return d.strftime(fmt); + } + var leftPad = function(n, pad) { + n = "" + n; + pad = "" + (pad == null ? "0" : pad); + return n.length == 1 ? pad + n : n; + }; + + var r = []; + var escape = false; + var hours = d.getHours(); + var isAM = hours < 12; + if (monthNames == null) + monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + if (dayNames == null) + dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + var hours12; + if (hours > 12) { + hours12 = hours - 12; + } else if (hours == 0) { + hours12 = 12; + } else { + hours12 = hours; + } + + for (var i = 0; i < fmt.length; ++i) { + var c = fmt.charAt(i); + + if (escape) { + switch (c) { + case 'a': c = "" + dayNames[d.getDay()]; break; + case 'b': c = "" + monthNames[d.getMonth()]; break; + case 'd': c = leftPad(d.getDate()); break; + case 'e': c = leftPad(d.getDate(), " "); break; + case 'H': c = leftPad(hours); break; + case 'I': c = leftPad(hours12); break; + case 'l': c = leftPad(hours12, " "); break; + case 'm': c = leftPad(d.getMonth() + 1); break; + case 'M': c = leftPad(d.getMinutes()); break; + case 'S': c = leftPad(d.getSeconds()); break; + case 'y': c = leftPad(d.getFullYear() % 100); break; + case 'Y': c = "" + d.getFullYear(); break; + case 'p': c = (isAM) ? ("" + "am") : ("" + "pm"); break; + case 'P': c = (isAM) ? ("" + "AM") : ("" + "PM"); break; + case 'w': c = "" + d.getDay(); break; + } + r.push(c); + escape = false; + } + else { + if (c == "%") + escape = true; + else + r.push(c); + } + } + return r.join(""); + } + + // To have a consistent view of time-based data independent of which time + // zone the client happens to be in we need a date-like object independent + // of time zones. This is done through a wrapper that only calls the UTC + // versions of the accessor methods. + function makeUtcWrapper(d) { + function addProxyMethod(sourceObj, sourceMethod, targetObj, + targetMethod) { + sourceObj[sourceMethod] = function() { + return targetObj[targetMethod].apply(targetObj, arguments); + }; + }; + var utc = { + date: d + }; + // support strftime, if found + if (d.strftime != undefined) + addProxyMethod(utc, "strftime", d, "strftime"); + addProxyMethod(utc, "getTime", d, "getTime"); + addProxyMethod(utc, "setTime", d, "setTime"); + var props = [ "Date", "Day", "FullYear", "Hours", "Milliseconds", "Minutes", "Month", "Seconds" ]; + for (var p = 0; p < props.length; p++) { + addProxyMethod(utc, "get" + props[p], d, "getUTC" + props[p]); + addProxyMethod(utc, "set" + props[p], d, "setUTC" + props[p]); + } + return utc; + }; + + // select time zone strategy. This returns a date-like object tied to the + // desired timezone + function dateGenerator(ts, opts) { + if (opts.timezone == "browser") { + return new Date(ts); + } else if (!opts.timezone || opts.timezone == "utc") { + return makeUtcWrapper(new Date(ts)); + } else if (typeof timezoneJS != "undefined" && typeof timezoneJS.Date != "undefined") { + var d = new timezoneJS.Date(); + // timezone-js is fickle, so be sure to set the time zone before + // setting the time. + d.setTimezone(opts.timezone); + d.setTime(ts); + return d; + } else { + return makeUtcWrapper(new Date(ts)); + } + } + + // map of app. size of time units in milliseconds + var timeUnitSize = { + "second": 1000, + "minute": 60 * 1000, + "hour": 60 * 60 * 1000, + "day": 24 * 60 * 60 * 1000, + "month": 30 * 24 * 60 * 60 * 1000, + "year": 365.2425 * 24 * 60 * 60 * 1000 + }; + + // the allowed tick sizes, after 1 year we use + // an integer algorithm + var spec = [ + [1, "second"], [2, "second"], [5, "second"], [10, "second"], + [30, "second"], + [1, "minute"], [2, "minute"], [5, "minute"], [10, "minute"], + [30, "minute"], + [1, "hour"], [2, "hour"], [4, "hour"], + [8, "hour"], [12, "hour"], + [1, "day"], [2, "day"], [3, "day"], + [0.25, "month"], [0.5, "month"], [1, "month"], + [2, "month"], [3, "month"], [6, "month"], + [1, "year"] + ]; + + function init(plot) { + plot.hooks.processDatapoints.push(function (plot, series, datapoints) { + $.each(plot.getAxes(), function(axisName, axis) { + var opts = axis.options; + if (opts.mode == "time") { + axis.tickGenerator = function(axis) { + var ticks = [], + d = dateGenerator(axis.min, opts), + minSize = 0; + + if (opts.minTickSize != null) { + if (typeof opts.tickSize == "number") + minSize = opts.tickSize; + else + minSize = opts.minTickSize[0] * timeUnitSize[opts.minTickSize[1]]; + } + + for (var i = 0; i < spec.length - 1; ++i) + if (axis.delta < (spec[i][0] * timeUnitSize[spec[i][1]] + + spec[i + 1][0] * timeUnitSize[spec[i + 1][1]]) / 2 + && spec[i][0] * timeUnitSize[spec[i][1]] >= minSize) + break; + var size = spec[i][0]; + var unit = spec[i][1]; + + // special-case the possibility of several years + if (unit == "year") { + // if given a minTickSize in years, just use it, + // ensuring that it's an integer + if (opts.minTickSize != null && opts.minTickSize[1] == "year") { + size = Math.floor(opts.minTickSize[0]); + } else { + var magn = Math.pow(10, Math.floor(Math.log(axis.delta / timeUnitSize.year) / Math.LN10)); + var norm = (axis.delta / timeUnitSize.year) / magn; + if (norm < 1.5) + size = 1; + else if (norm < 3) + size = 2; + else if (norm < 7.5) + size = 5; + else + size = 10; + + size *= magn; + } + + // minimum size for years is 1 + if (size < 1) + size = 1; + } + + axis.tickSize = opts.tickSize || [size, unit]; + var tickSize = axis.tickSize[0]; + unit = axis.tickSize[1]; + + var step = tickSize * timeUnitSize[unit]; + + if (unit == "second") + d.setSeconds(floorInBase(d.getSeconds(), tickSize)); + if (unit == "minute") + d.setMinutes(floorInBase(d.getMinutes(), tickSize)); + if (unit == "hour") + d.setHours(floorInBase(d.getHours(), tickSize)); + if (unit == "month") + d.setMonth(floorInBase(d.getMonth(), tickSize)); + if (unit == "year") + d.setFullYear(floorInBase(d.getFullYear(), tickSize)); + + // reset smaller components + d.setMilliseconds(0); + if (step >= timeUnitSize.minute) + d.setSeconds(0); + if (step >= timeUnitSize.hour) + d.setMinutes(0); + if (step >= timeUnitSize.day) + d.setHours(0); + if (step >= timeUnitSize.day * 4) + d.setDate(1); + if (step >= timeUnitSize.year) + d.setMonth(0); + + + var carry = 0, v = Number.NaN, prev; + do { + prev = v; + v = d.getTime(); + ticks.push(v); + if (unit == "month") { + if (tickSize < 1) { + // a bit complicated - we'll divide the month + // up but we need to take care of fractions + // so we don't end up in the middle of a day + d.setDate(1); + var start = d.getTime(); + d.setMonth(d.getMonth() + 1); + var end = d.getTime(); + d.setTime(v + carry * timeUnitSize.hour + (end - start) * tickSize); + carry = d.getHours(); + d.setHours(0); + } + else + d.setMonth(d.getMonth() + tickSize); + } + else if (unit == "year") { + d.setFullYear(d.getFullYear() + tickSize); + } + else + d.setTime(v + step); + } while (v < axis.max && v != prev); + + return ticks; + }; + + axis.tickFormatter = function (v, axis) { + var d = dateGenerator(v, axis.options); + + // first check global format + if (opts.timeformat != null) + return formatDate(d, opts.timeformat, opts.monthNames, opts.dayNames); + + var t = axis.tickSize[0] * timeUnitSize[axis.tickSize[1]]; + var span = axis.max - axis.min; + var suffix = (opts.twelveHourClock) ? " %p" : ""; + var hourCode = (opts.twelveHourClock) ? "%I" : "%H"; + + if (t < timeUnitSize.minute) + fmt = hourCode + ":%M:%S" + suffix; + else if (t < timeUnitSize.day) { + if (span < 2 * timeUnitSize.day) + fmt = hourCode + ":%M" + suffix; + else + fmt = "%b %d " + hourCode + ":%M" + suffix; + } + else if (t < timeUnitSize.month) + fmt = "%b %d"; + else if (t < timeUnitSize.year) { + if (span < timeUnitSize.year) + fmt = "%b"; + else + fmt = "%b %Y"; + } + else + fmt = "%Y"; + + var rt = formatDate(d, fmt, opts.monthNames, opts.dayNames); + return rt; + }; + } + }); + }); + } + + $.plot.plugins.push({ + init: init, + options: options, + name: 'time', + version: '1.0' + }); +})(jQuery); diff --git a/r2/r2/public/static/js/timeseries.js b/r2/r2/public/static/js/timeseries.js new file mode 100644 index 000000000..fb007b4bb --- /dev/null +++ b/r2/r2/public/static/js/timeseries.js @@ -0,0 +1,177 @@ +r.timeseries = { + _tickSizeByInterval: { + 'hour': [1, 'day'], + 'day': [7, 'day'], + 'month': [1, 'month'] + }, + _units : ['', 'k', 'M', 'B'], + _tooltip: null, + _currentHover: null, + + init: function () { + $('table.timeseries').each($.proxy(function (i, table) { + var series = this.makeTimeSeriesChartsFromTable(table) + this.addBarsToTable(table, series) + }, this)) + }, + + _formatTick: function (val, axis) { + if (val == 0) + return '0' + else if (val < 10) + return val.toFixed(2) + + for (var i = 1; i < this._units.length; i++) { + if (val / Math.pow(1000, i - 1) < 1000) { + break + } + } + + val /= Math.pow(1000, i - 1) + + return val.toFixed(axis.tickDecimals) + this._units[i - 1] + }, + + makeTimeSeriesChartsFromTable: function (table) { + var table = $(table), + series = this._readFromTable(table), + options = this._configureFlot(table), + chartRow = $('
') + + $.each(series, function (i, chart) { + var figure = $('
') + .append($('').text(chart.caption)) + .append('
') + .appendTo(chartRow) + + chart.placeholder = figure.find('.timeseries-placeholder') + }) + $('#charts').append(chartRow) + + $.each(series, function(i, chart) { + $.plot(chart.placeholder, [chart], options) + }) + + table.addClass('charted') + + return series + }, + + addBarsToTable: function (table, series) { + var table = $(table), + newColHeader = $('') + + table.find('thead tr').append(newColHeader) + + table.find('tbody tr').each(function (i, row) { + var row = $(row), + data = row.children('td'), + newcol = $('') + + $.each(series, function (i, s) { + var datum = data.eq(s.index).data('value'), + bar = $('
').addClass('timeseries-tablebar') + .css('background-color', s.color) + .width((datum / s.maxValue) * 100) + + if (datum > 0) + newcol.append(bar) + + if (i === 0 && datum !== 0 && datum === s.maxValue) + row.addClass('max') + }) + + row.append(newcol) + }) + }, + + _configureFlot: function (table) { + var interval = table.data('interval'), + tickUnit = this._tickSizeByInterval[interval], + unprocessed = $('#timeseries-unprocessed').data('last-processed'), + markings = [] + + if (unprocessed) { + markings.push({ + color: '#eee', + xaxis: { + from: unprocessed + } + }) + + markings.push({ + color: '#aaa', + xaxis: { + from: unprocessed, + to: unprocessed + } + }) + } + + return { + grid: { + 'markings': markings + }, + + xaxis: { + mode: 'time', + tickSize: tickUnit + }, + + yaxis: { + min: 0, + tickFormatter: $.proxy(this, '_formatTick') + } + } + }, + + _readFromTable: function (table) { + var maxPoints = parseInt(table.data('maxPoints'), 10), + headers = table.find('thead tr:last-child th:not(:first-child)'), + series = [] + + // initialize the series + headers.each(function (i, header) { + var header = $(header), + caption = header.attr('title'), + color = header.data('color') + + if (!color) { + return + } + + series.push({ + 'lines': { + 'show': true, + 'steps': true, + 'fill': true + }, + + 'color': color, + 'caption': caption, + 'data': [], + 'maxValue': 0, + 'index': i + }) + }) + + // read the data from the table + var rows = table.find('tbody tr') + rows.each(function (i, row) { + var row = $(row), + timestamp = row.children('th').data('value'), + data = row.children('td') + + $.each(series, function (j, s) { + var datum = data.eq(s.index).data('value') + // if we have a maximum number of data points to chart, choose + // the most recent ones from the table + if (!maxPoints || i > rows.length - maxPoints) + s.data.push([timestamp, datum]) + s.maxValue = Math.max(s.maxValue, datum) + }) + }) + + return series + } +} diff --git a/r2/r2/public/static/js/traffic.js b/r2/r2/public/static/js/traffic.js new file mode 100644 index 000000000..86ff3673c --- /dev/null +++ b/r2/r2/public/static/js/traffic.js @@ -0,0 +1,33 @@ +r.traffic = { + init: function () { + // add a simple method of jumping to any subreddit's traffic page + if ($('body').hasClass('traffic-sitewide')) + this.addSubredditSelector() + }, + + addSubredditSelector: function () { + $('
').append( + $('
').append( + $('').text(r.strings.view_subreddit_traffic), + $(''), + $('').attr('value', r.strings.go) + ) + ).submit(r.traffic._onSubredditSelected) + .prependTo('.traffic-tables-side') + }, + + _onSubredditSelected: function () { + var srname = $(this.srname).val() + + window.location = window.location.protocol + '//' + + r.config.cur_domain + + '/r/' + srname + + '/about/traffic' + + return false + } +} + +$(function () { + r.traffic.init() +}) diff --git a/r2/r2/templates/adverttrafficsummary.html b/r2/r2/templates/adverttrafficsummary.html new file mode 100644 index 000000000..7e9eb486f --- /dev/null +++ b/r2/r2/templates/adverttrafficsummary.html @@ -0,0 +1,54 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="reddittraffic.html"/> + +<%! + from r2.lib.template_helpers import format_number +%> + +<%def name="sidetables()"> + + + + + + + + + + + % for (name, url), data in thing.advert_summary: + + + % for datum in data: + + % endfor + + % endfor + +
${_("top adverts")}
${_("ad")}${_("uniques")}${_("impressions")}
${name[:25]}${format_number(datum)}
+ + +<%def name="tables()"> + ${thing.totals} + diff --git a/r2/r2/templates/languagetrafficsummary.html b/r2/r2/templates/languagetrafficsummary.html new file mode 100644 index 000000000..6e49f3c6b --- /dev/null +++ b/r2/r2/templates/languagetrafficsummary.html @@ -0,0 +1,48 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%! + from r2.lib.template_helpers import format_number +%> + +

${_("language traffic statistics")}

+ + + + + + + + + + + +% for (langcode, langname), data in thing.language_summary: + + + % for datum in data: + + % endfor + +% endfor + +
${_("traffic by language")}
${_("language")}${_("uniques")}${_("pageviews")}
${langname}${format_number(datum)}
diff --git a/r2/r2/templates/promote_graph.html b/r2/r2/templates/promote_graph.html index e36893d65..dcae60839 100644 --- a/r2/r2/templates/promote_graph.html +++ b/r2/r2/templates/promote_graph.html @@ -28,14 +28,17 @@ import babel.numbers locale = c.locale + def num(x): return format_number(x, locale) def money(x): return babel.numbers.format_currency(x, 'USD', locale) %> -

Sponsored link calendar

-${unsafe(js.use('flot'))} +<%namespace file="reddittraffic.html" import="load_timeseries_js"/> +${load_timeseries_js()} + +

${_("Sponsored link calendar")}

%if not c.user_is_sponsor:
@@ -171,114 +174,12 @@ ${unsafe(js.use('flot'))}
-%if c.user_is_sponsor: -

${_("total promotion traffic")}

-
- %if thing.imp_graph: - ${thing.imp_graph} - %endif - %if thing.cli_graph: - ${thing.cli_graph} - %endif - %if thing.money_graph: - ${thing.money_graph} - %endif -
-%endif -

${_("historical site performance")}

-
-%if thing.cpm_graph: - ${thing.cpm_graph} -%endif -%if thing.cpc_graph: - ${thing.cpc_graph} -%endif -%if thing.cpc_graph and c.user_is_sponsor: - ${thing.ctr_graph} -%endif +
+ +${thing.performance_table} + +
- -%if c.user_is_sponsor: -
- %if thing.top_promoters: -

${_('top promoters this month')}

- - - - - - - - - - - %for account, bid, refund, promos in thing.top_promoters: - - - - - - - - - %endfor - %if len(thing.top_promoters) != 1: - <% - totals = zip(*thing.top_promoters) - total_bid = sum(totals[1]) - total_refund = sum(totals[2]) - total_promos = sum(map(len, totals[3])) - %> - - - - - - - - - %endif -
userpromosbidscreditstotalper promo
${account.name}${len(promos)}${money(bid)}${money(refund)}${money(bid - refund)}${money((bid - refund)/len(promos))}
Total${total_promos}${money(total_bid)}${money(total_refund)}${money(total_bid - total_refund)}${money((total_bid - total_refund)/total_promos)}
- %endif - - %if thing.recent: -

${_('promotions this month')} [CSV]

- - - - - - - - - - - - - - - - - - - %for link, uimp, nimp, ucli, ncli in thing.recent: - - - - - - - -## - - - - %endfor -
ImpresionsClicks
dateuniquetotaluniquetotalpricepointstitle
- ${link._date.strftime("%Y-%m-%d")} - ${num(uimp)}${num(nimp)}${num(ucli)}${num(ncli)}TODO${money(link.promote_bid)}${link._ups - link._downs} - ${link.title} -
- %endif -%endif diff --git a/r2/r2/templates/promotedlinktraffic.html b/r2/r2/templates/promotedlinktraffic.html new file mode 100644 index 000000000..c97b84c0e --- /dev/null +++ b/r2/r2/templates/promotedlinktraffic.html @@ -0,0 +1,103 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="reddittraffic.html"/> +<%namespace file="reddittraffic.html" import="load_timeseries_js"/> + +<%! + from r2.lib.strings import strings + from r2.lib.filters import safemarkdown + from r2.lib.template_helpers import format_number, js_timestamp +%> + +<%def name="preamble()"> + ${unsafe(safemarkdown(strings.traffic_promoted_link_explanation))} + + ${thing.viewer_list} + +

+ ${_("promotion traffic")} + + ${_("(download as .csv)")} + +

+ + ${load_timeseries_js()} + + +<%def name="sidetables()" /> + +<%def name="tables()"> + + + + + + + + + + + + + + + + + + + + % for date, data in thing.history: + + + % for datum in data: + + % endfor + + % endfor + + + + + + + + + + % if thing.total_impressions != 0: + + % else: + + % endif + + +
${_("impressions")}${_("clicks")}${_("click-through (%)")}
${_("date")}${_("unique")}${_("total")}${_("unique")}${_("total")}${_("unique")}${_("total")}
${date}${format_number(datum)}
+ ${_("total")} + % if thing.is_preliminary: + * + % endif + --${format_number(thing.total_impressions)}--${format_number(thing.total_clicks)}--${format_number(thing.total_ctr)}%--
+ + % if thing.is_preliminary: +

* ${_("totals are preliminary until 24 hours after the end of the promotion.")}

+ % endif + diff --git a/r2/r2/templates/promotedtraffic.html b/r2/r2/templates/promotedtraffic.html deleted file mode 100644 index 1c48f2bcf..000000000 --- a/r2/r2/templates/promotedtraffic.html +++ /dev/null @@ -1,117 +0,0 @@ -## The contents of this file are subject to the Common Public Attribution -## License Version 1.0. (the "License"); you may not use this file except in -## compliance with the License. You may obtain a copy of the License at -## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public -## License Version 1.1, but Sections 14 and 15 have been added to cover use of -## software over a computer network and provide for limited attribution for the -## Original Developer. In addition, Exhibit A has been modified to be -## consistent with Exhibit B. -## -## Software distributed under the License is distributed on an "AS IS" basis, -## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for -## the specific language governing rights and limitations under the License. -## -## The Original Code is reddit. -## -## The Original Developer is the Initial Developer. The Initial Developer of -## the Original Code is reddit Inc. -## -## All portions of the code written by reddit are Copyright (c) 2006-2012 -## reddit Inc. All Rights Reserved. -############################################################################### - -<%! - from r2.lib.template_helpers import static, format_number - from r2.lib import js - from r2.models.subreddit import DomainSR, FakeSubreddit - locale = c.locale - def num(x): - return format_number(x, locale) - %> - -${unsafe(js.use('flot'))} - - diff --git a/r2/r2/templates/reddittraffic.html b/r2/r2/templates/reddittraffic.html index 53adf8a19..c5577d801 100644 --- a/r2/r2/templates/reddittraffic.html +++ b/r2/r2/templates/reddittraffic.html @@ -21,241 +21,93 @@ ############################################################################### <%! - from r2.lib.template_helpers import static, format_number - from r2.lib import js - from r2.models.subreddit import DomainSR, FakeSubreddit - locale = c.locale - def num(x): - return format_number(x, locale) - %> + import babel.dates -${unsafe(js.use('flot'))} + from r2.lib import js + from r2.lib.strings import strings + from r2.lib.template_helpers import static, js_timestamp, format_number -<%def name="daily_summary()"> - <% - thing.day_data = filter(None, thing.day_data) - umin = min(data[0] for date, data in thing.day_data) - if len(filter(lambda x: x[1][0] == umin, thing.day_data)) > 1: - umin = -1 - umax = max(data[0] for date, data in filter(None, thing.day_data)) - %> - - - %if c.site.domain: - - - - - - - - - - - %else: - - - - %if not c.default_sr: - - %endif - - %endif - - %for x, (date, data) in enumerate(thing.day_data): - - - <% - indx = range(5) if c.site.domain else \ - [0,1,4] if not c.default_sr else [0,1] - %> - %for i in indx: - - %endfor - ${bars(data[0], data[1])} - - %endfor -
total${c.site.domain}
${_("date")}${_("uniques")}${_("impressions")}${_("uniques")}${_("impressions")}${_("subscriptions")}${_("date")}${_("uniques")}${_("impressions")}${_("subscriptions")}
${date.strftime("%Y-%m-%d")}${num(data[i]) if data[i] else "-"}
+ import babel.dates +%> + +<%def name="load_timeseries_js()"> + + + ${unsafe(js.use('timeseries'))} + -<%def name="weekly_summary()"> - - - - - - - - - - <% - dow = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", - "Saturday", "Sunday"] - %> - %for i, (uni, imp) in enumerate(zip(thing.uniques_by_dow, thing.impressions_by_dow)): - - - - - ${bars(uni, imp)} - - %endfor - - - - - -
Weekly summary
${_("date")}${_("uniques")}${_("impressions")}
${dow[i]}${num(int(uni))}${num(int(imp))}
${_("Daily Mean")}${num(int(thing.uniques_mean))}${num(int(thing.impressions_mean))}
- +% if thing.place: +

${_("traffic statistics for %(place)s") % dict(place=thing.place)}

+% endif -

Traffic for ${c.site.name}

+${self.preamble()} -

- Below are the traffic stats for your reddit. Each graph represents one of the following over the interval specified -

-
    -
  • - pageviews are all hits to ${c.site.name}, including both listing pages and comment pages. -
  • -
  • - uniques are the total number of unique visitors (IP and U/A combo) that generate the above pageviews. This is independent of whether or not they are logged in. -
  • -
  • - subscriptions is the number of new subscriptions in a given day that have been generated. This number is less accurate than the first two metrics, as, though we can track new subscriptions, we have no way to track unsubscriptions (which are when a subscription is actually deleted from the db). -
  • -
-

- Note: there are a couple of places outside of your reddit where someone can click "subscribe", so it is possible (though unlikely) that the subscription count can exceed the unique count on a given day. +

+% if thing.traffic_lag.total_seconds() > 10800: +${strings.traffic_processing_slow % dict(date=babel.dates.format_datetime(thing.traffic_last_modified, format="long", locale=c.locale))} +% else: +${strings.traffic_processing_normal % dict(date=babel.dates.format_datetime(thing.traffic_last_modified, format="long", locale=c.locale))} +% endif

-%if not thing.has_data: -

- ${_("There doesn't seem to be any traffic data at the moment. Please check back later.")} -

-%else: - - - - - - - - - - - - - - %if not c.default_sr: - - - - - %endif -
- ${thing.uniques_hour} - - ${thing.impressions_hour} -
- ${thing.uniques_day} - - ${thing.impressions_day} -
- ${thing.uniques_month} - - ${thing.impressions_month} -
- ${thing.subscriptions_day} - -
+
-
- ${weekly_summary()} - %if c.default_sr: - ${daily_summary()} - %endif -
+
+${self.sidetables()} +
- %if c.default_sr: - <% data = thing.monthly_summary() %> - - - - - - - - - - %for i, d in enumerate(reversed(data)): - - %for cls, x in d: - - %endfor - - %endfor -
Monthly data
${_("date")}${_("uniques")}${_("impressions")}
${x}
- - - - - - - - - - - - - - - %for i, (sr, d) in enumerate(thing.reddits_summary()): - - - - %for x in d: - - %endfor - - - %endfor -
total${_("cnamed")}
${_("date")}${_("uniques")}${_("impressions")}${_("uniques")}${_("impressions")}${_("cname")}
- %if isinstance(sr, DomainSR): - [domain:${sr.name}] - %elif isinstance(sr, FakeSubreddit): - [meta:${sr.name}] - %else: - ${sr.name} - %endif - - ${num(x) if x else "--"} - %if not isinstance(sr, FakeSubreddit) and sr.domain: - ${sr.domain} - %endif -
- %else: - ${daily_summary()} - %endif -%endif +
+${self.tables()} +
+ +<%def name="preamble()" /> -<%def name="bars(uni, imp)"> +<%def name="sidetables()"> <% - wid_uni = int(50 * min(2, float(uni)/max(thing.uniques_mean, 1))) - wid_imp = int(50 * min(2, float(imp)/max(thing.impressions_mean,1))) - %> - -
-
-
-
- + day_names = babel.dates.get_day_names(locale=c.locale) + %> + + % if thing.dow_summary: + + + + + + + + + + + % for date, cols in thing.dow_summary: + + + % for col in cols: + + % endfor + + % endfor + + + + + % for col in thing.dow_means: + + % endfor + + +
${_("traffic by day of week")}
${_("day")}${_("uniques")}${_("pageviews")}
${day_names[date.weekday()]}${format_number(col)}
${_("daily mean")}${format_number(col)}
+ % endif +<%def name="tables()"> + % for table in thing.tables: + ${table} + % endfor + diff --git a/r2/r2/templates/sitewidetraffic.html b/r2/r2/templates/sitewidetraffic.html new file mode 100644 index 000000000..b8893be4f --- /dev/null +++ b/r2/r2/templates/sitewidetraffic.html @@ -0,0 +1,56 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="reddittraffic.html"/> + +<%! + from r2.lib.template_helpers import format_number +%> + +<%def name="sidetables()"> + ${parent.sidetables()} + + + + + + + + + + + + % for (name, url), data in thing.subreddit_summary: + + % if url: + + % else: + + % endif + % for datum in data: + + % endfor + + % endfor + +
${_("top subreddits")}
${_("subreddit")}${_("uniques")}${_("pageviews")}
${name}${name}${format_number(datum)}
+ diff --git a/r2/r2/templates/sitewidetrafficpage.html b/r2/r2/templates/sitewidetrafficpage.html new file mode 100644 index 000000000..3053d8077 --- /dev/null +++ b/r2/r2/templates/sitewidetrafficpage.html @@ -0,0 +1,32 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="trafficpage.html"/> + +<%! + from r2.lib import js +%> + +<%def name="javascript()"> + ${parent.javascript()} + ${unsafe(js.use('traffic'))} + diff --git a/r2/r2/templates/subreddittraffic.html b/r2/r2/templates/subreddittraffic.html new file mode 100644 index 000000000..d9a980b25 --- /dev/null +++ b/r2/r2/templates/subreddittraffic.html @@ -0,0 +1,44 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="reddittraffic.html"/> +<%namespace file="reddittraffic.html" import="load_timeseries_js"/> + +<%! + from r2.lib.filters import safemarkdown + from r2.lib.strings import strings +%> + +<%def name="preamble()"> + % if c.user_is_sponsor: + + % else: + ${unsafe(safemarkdown(strings.traffic_subreddit_explanation % dict(subreddit=thing.place)))} + % endif + + ${load_timeseries_js()} + diff --git a/r2/r2/templates/timeserieschart.html b/r2/r2/templates/timeserieschart.html new file mode 100644 index 000000000..39c996490 --- /dev/null +++ b/r2/r2/templates/timeserieschart.html @@ -0,0 +1,69 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be consistent +## with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is Reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is CondeNet, Inc. +## +## All portions of the code written by CondeNet are Copyright (c) 2006-2010 +## CondeNet, Inc. All Rights Reserved. +################################################################################ + +<%! + import time + import babel.dates + from r2.lib.template_helpers import js_timestamp, format_number +%> + +<% + month_names = babel.dates.get_month_names(locale=c.locale) +%> + + + + + + + % for col in thing.columns: + % if "color" in col: + + % else: + + % endif + % endfor + + + +% for date, data in thing.rows: + + + % for datum in data: + % if date < thing.latest_available_data: + + % else: + + % endif + % endfor + +% endfor + +
${thing.title}
${_("date")}${col['shortname']}${col['shortname']}
+ % if thing.interval == "hour": + ${babel.dates.format_datetime(date, format="short", locale=c.locale)} + % elif thing.interval == "day": + ${babel.dates.format_date(date, format="short", locale=c.locale)} + % else: + ${month_names[date.month]} + % endif + ${format_number(datum)}${_("unavailable")}
diff --git a/r2/r2/templates/trafficgraph.html b/r2/r2/templates/trafficgraph.html deleted file mode 100644 index b01b1a699..000000000 --- a/r2/r2/templates/trafficgraph.html +++ /dev/null @@ -1,75 +0,0 @@ -## The contents of this file are subject to the Common Public Attribution -## License Version 1.0. (the "License"); you may not use this file except in -## compliance with the License. You may obtain a copy of the License at -## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public -## License Version 1.1, but Sections 14 and 15 have been added to cover use of -## software over a computer network and provide for limited attribution for the -## Original Developer. In addition, Exhibit A has been modified to be -## consistent with Exhibit B. -## -## Software distributed under the License is distributed on an "AS IS" basis, -## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for -## the specific language governing rights and limitations under the License. -## -## The Original Code is reddit. -## -## The Original Developer is the Initial Developer. The Initial Developer of -## the Original Code is reddit Inc. -## -## All portions of the code written by reddit are Copyright (c) 2006-2012 -## reddit Inc. All Rights Reserved. -############################################################################### - -<%! - from random import random - import simplejson - %> - - -<% - _id = str(random()).split('.')[-1] - %> -
-
${thing.title}
-
-
-
- - diff --git a/r2/r2/templates/trafficpage.html b/r2/r2/templates/trafficpage.html new file mode 100644 index 000000000..294db6a27 --- /dev/null +++ b/r2/r2/templates/trafficpage.html @@ -0,0 +1,29 @@ +## The contents of this file are subject to the Common Public Attribution +## License Version 1.0. (the "License"); you may not use this file except in +## compliance with the License. You may obtain a copy of the License at +## http://code.reddit.com/LICENSE. The License is based on the Mozilla Public +## License Version 1.1, but Sections 14 and 15 have been added to cover use of +## software over a computer network and provide for limited attribution for the +## Original Developer. In addition, Exhibit A has been modified to be +## consistent with Exhibit B. +## +## Software distributed under the License is distributed on an "AS IS" basis, +## WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for +## the specific language governing rights and limitations under the License. +## +## The Original Code is reddit. +## +## The Original Developer is the Initial Developer. The Initial Developer of +## the Original Code is reddit Inc. +## +## All portions of the code written by reddit are Copyright (c) 2006-2012 +## reddit Inc. All Rights Reserved. +############################################################################### + +<%inherit file="reddit.html"/> +<%namespace file="reddittraffic.html" import="load_timeseries_js"/> + +<%def name="javascript()"> + ${parent.javascript()} + ${load_timeseries_js()} +