Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dygraph showing dates in an arbitrary Timezone

I needed to get Dygraph to respect arbitrary timezones. All dates shown in/on/around the graph should be able to be shown in an arbitrary timezone.

The problem:

  • Javascript does not support timezones other than the browser local timezone.
  • There are libraries out there (timezone-js, moment-js with moment-timezone-js) to address this, but Dygraph doesn't know about them.
like image 678
Eddified Avatar asked Mar 20 '23 08:03

Eddified


2 Answers

UPDATED answer, now works with Dygraph version 1.1.1. For old answer skip past horizontal rule.

There is now an easier way to make Dygraph work with arbitrary timezones. The old way (implementing custom valueFormatter, axisLabelFormatter, and ticker functions) should theoretically still work provided the functions are compatible (the code in the old answer is not compatible with Dygraph v. 1.1.1.)

However, I decided to go a different route this time around. This new way does not use documented options, so it is a bit more hacky. However, it does have the benefit of being much less code (no need to re-implement the ticker function, etc). So while it is a little hacky, it's an elegant hack IMO. Note that the new version still requires moment and moment timezone 3rd party libraries.

Note that this version requires that you use the new labelsUTC option, set to true. You can think of this hack as turning Dygraph's UTC option into a timezone of your choosing.

var g_timezoneName = 'America/Los_Angeles'; // US Pacific Time

function getMomentTZ(d, interpret) {
    // Always setting a timezone seems to prevent issues with daylight savings time boundaries, even when the timezone we are setting is the same as the browser: https://github.com/moment/moment/issues/1709
    // The moment tz docs state this:
    //  moment.tz(..., String) is used to create a moment with a timezone, and moment().tz(String) is used to change the timezone on an existing moment.
    // Here is some code demonstrating the difference.
    //  d = new Date()
    //  d.getTime() / 1000                                   // 1448297005.27
    //  moment(d).tz(tzStringName).toDate().getTime() / 1000 // 1448297005.27
    //  moment.tz(d, tzStringName).toDate().getTime() / 1000 // 1448300605.27
    if (interpret) {
        return moment.tz(d, g_timezoneName); // if d is a javascript Date object, the resulting moment may have a *different* epoch than the input Date d.
    } else {
        return moment(d).tz(g_timezoneName); // does not change epoch value, just outputs same epoch value as different timezone
    }
}

/** Elegant hack: overwrite Dygraph's DateAccessorsUTC to return values
 * according to the currently selected timezone (which is stored in
 * g_timezoneName) instead of UTC.
 * This hack has no effect unless the 'labelsUTC' setting is true. See Dygraph
 * documentation regarding labelsUTC flag.
 */
Dygraph.DateAccessorsUTC = {
    getFullYear:     function(d) {return getMomentTZ(d, false).year();},
    getMonth:        function(d) {return getMomentTZ(d, false).month();},
    getDate:         function(d) {return getMomentTZ(d, false).date();},
    getHours:        function(d) {return getMomentTZ(d, false).hour();},
    getMinutes:      function(d) {return getMomentTZ(d, false).minute();},
    getSeconds:      function(d) {return getMomentTZ(d, false).second();},
    getMilliseconds: function(d) {return getMomentTZ(d, false).millisecond();},
    getDay:          function(d) {return getMomentTZ(d, false).day();},
    makeDate:        function(y, m, d, hh, mm, ss, ms) {
        return getMomentTZ({
            year: y,
            month: m,
            day: d,
            hour: hh,
            minute: mm,
            second: ss,
            millisecond: ms,
        }, true).toDate();
    },
};

// ok, now be sure to set labelsUTC option to true
var graphoptions = {
  labels: ['Time', 'Impressions', 'Clicks'],
  labelsUTC: true
};
var g = new Dygraph(chart, data, graphoptions);

(Note: I still like to specify valueFormatter because I want labels in "YYYY-MM-DD" format instead of "YYYY/MM/DD", but it's not necessary to specify valueFormatter anymore just to get arbitrary timezone support.)

Tested using Dygraph 1.1.1, Moment 2.10.6, and Moment Timezone v 0.4.1


Old answer, works with Dygraph version 1.0.1.

My first attempt was to simply pass Dygraph some timezone-js objects instead of Date objects, due to their purported ability to be used as drop-in replacements for javascript Date objects (you can use them like Date objects). Unfortunately this didn't work because Dygraph creates Date objects from scratch and doesn't seem to use the timezone-js ones I'm passing to it.

Digging into the Dygraph documentation shows that there are some hooks that we can use to override how dates are displayed:

valueFormatter

Function to provide a custom display format for the values displayed on mouseover. This does not affect the values that appear on tick marks next to the axes. To format those, see axisLabelFormatter.

axisLabelFormatter

Function to call to format the tick values that appear along an axis. This is usually set on a per-axis basis.

ticker

This lets you specify an arbitrary function to generate tick marks on an axis. The tick marks are an array of (value, label) pairs. The built-in functions go to great lengths to choose good tick marks so, if you set this option, you'll most likely want to call one of them and modify the result.

That last one actually depends on the timezone too, so we'll need to override it as well as the formatters.

So I first wrote replacements for valueFormatter and axisLabelFormatter using timezone-js, but it turns out timezone-js didn't actually work correctly for non-DST dates (browser currently in DST). So first I setup moment-js, moment-timezone-js, and the timezone data I need. (For this example we only need 'Etc/UTC'). Note that I am using a global variable to store the timezone that I pass to moment-timezone-js. If you come up with a better way, please comment. Here are the valueFormatter and axisLabelFormatters I wrote using moment-js:

var g_timezoneName = 'Etc/UTC'; // UTC

/*
   Copied the Dygraph.dateAxisFormatter function and modified it to not create a new Date object, and to use moment-js
 */
function dateAxisFormatter(date, granularity) {
    var mmnt = moment(date).tz(g_timezoneName);
    if (granularity >= Dygraph.DECADAL) {
        return mmnt.format('YYYY');
    } else if (granularity >= Dygraph.MONTHLY) {
        return mmnt.format('MMM YYYY');
    } else {
        var frac = mmnt.hour() * 3600 + mmnt.minute() * 60 + mmnt.second() + mmnt.millisecond();
        if (frac === 0 || granularity >= Dygraph.DAILY) {
            return mmnt.format('DD MMM');
        } else {
            return hmsString_(mmnt);
        }
    }
}
  
/*
   Copied the Dygraph.dateString_ function and modified it to use moment-js
 */
function valueFormatter(date_millis) {
    var mmnt = moment(date_millis).tz(g_timezoneName);
    var frac = mmnt.hour() * 3600 + mmnt.minute() * 60 + mmnt.second();
    if (frac) {
        return mmnt.format('YYYY-MM-DD') + ' ' + hmsString_(mmnt);
    }
    return mmnt.format('YYYY-MM-DD');
}
  
/*
    Format hours, mins, seconds, but leave off seconds if they are zero
    @param mmnt - moment object
 */
function hmsString_(mmnt) {
    if (mmnt.second()) {
        return mmnt.format('HH:mm:ss');
    } else {
        return mmnt.format('HH:mm');
    }
}

While testing these formatters, I noticed that the tick marks were a little strange. For instance, my graph covered two days worth of data, yet I didn't see any dates in the tick labels. Instead, I only saw time values. Dygraph by default would show a date in the middle when the graph covers two days of data.

So, to fix this we need to supply our own Ticker. What better ticker to use than a modified version of Dygraph's?

/** @type {Dygraph.Ticker}
    Copied from Dygraph.dateTicker. Using our own function to getAxis, which respects TZ
*/
function customDateTickerTZ(a, b, pixels, opts, dygraph, vals) {   
  var chosen = Dygraph.pickDateTickGranularity(a, b, pixels, opts);
  if (chosen >= 0) {
    return getDateAxis(a, b, chosen, opts, dygraph); // use own own function here
  } else {
    // this can happen if self.width_ is zero.
    return [];
  }
};

/** 
 *  Copied from Dygraph.getDateAxis - modified to respect TZ
 * @param {number} start_time
 * @param {number} end_time
 * @param {number} granularity (one of the granularities enumerated in Dygraph code)
 * @param {function(string):*} opts Function mapping from option name -> value.
 * @param {Dygraph=} dg
 * @return {!Dygraph.TickList}
 */
function getDateAxis(start_time, end_time, granularity, opts, dg) {
  var formatter = /** @type{AxisLabelFormatter} */(
      opts("axisLabelFormatter"));
  var ticks = [];
  var t; 
    
  if (granularity < Dygraph.MONTHLY) {
    // Generate one tick mark for every fixed interval of time.
    var spacing = Dygraph.SHORT_SPACINGS[granularity];

    // Find a time less than start_time which occurs on a "nice" time boundary
    // for this granularity.
    var g = spacing / 1000;
    var d = moment(start_time);
    d.tz(g_timezoneName); // setting a timezone seems to prevent issues with daylight savings time boundaries, even when the timezone we are setting is the same as the browser: https://github.com/moment/moment/issues/1709
    d.millisecond(0);
    
    var x;
    if (g <= 60) {  // seconds 
      x = d.second();         
      d.second(x - x % g);     
    } else {
      d.second(0);
      g /= 60; 
      if (g <= 60) {  // minutes
        x = d.minute();
        d.minute(x - x % g);
      } else {
        d.minute(0);
        g /= 60;

        if (g <= 24) {  // days
          x = d.hour();
          d.hour(x - x % g);
        } else {
          d.hour(0);
          g /= 24;

          if (g == 7) {  // one week
            d.startOf('week');
          }
        }
      }
    }
    start_time = d.valueOf();

    // For spacings coarser than two-hourly, we want to ignore daylight
    // savings transitions to get consistent ticks. For finer-grained ticks,
    // it's essential to show the DST transition in all its messiness.
    var start_offset_min = moment(start_time).tz(g_timezoneName).zone();
    var check_dst = (spacing >= Dygraph.SHORT_SPACINGS[Dygraph.TWO_HOURLY]);

    for (t = start_time; t <= end_time; t += spacing) {
      d = moment(t).tz(g_timezoneName);

      // This ensures that we stay on the same hourly "rhythm" across
      // daylight savings transitions. Without this, the ticks could get off
      // by an hour. See tests/daylight-savings.html or issue 147.
      if (check_dst && d.zone() != start_offset_min) {
        var delta_min = d.zone() - start_offset_min;
        t += delta_min * 60 * 1000;
        d = moment(t).tz(g_timezoneName);
        start_offset_min = d.zone();

        // Check whether we've backed into the previous timezone again.
        // This can happen during a "spring forward" transition. In this case,
        // it's best to skip this tick altogether (we may be shooting for a
        // non-existent time like the 2AM that's skipped) and go to the next
        // one.
        if (moment(t + spacing).tz(g_timezoneName).zone() != start_offset_min) {
          t += spacing;
          d = moment(t).tz(g_timezoneName);
          start_offset_min = d.zone();
        }
      }

      ticks.push({ v:t,
                   label: formatter(d, granularity, opts, dg)
                 });
    }
  } else {
    // Display a tick mark on the first of a set of months of each year.
    // Years get a tick mark iff y % year_mod == 0. This is useful for
    // displaying a tick mark once every 10 years, say, on long time scales.
    var months;
    var year_mod = 1;  // e.g. to only print one point every 10 years.
    if (granularity < Dygraph.NUM_GRANULARITIES) {
      months = Dygraph.LONG_TICK_PLACEMENTS[granularity].months;
      year_mod = Dygraph.LONG_TICK_PLACEMENTS[granularity].year_mod;
    } else {
      Dygraph.warn("Span of dates is too long");
    }

    var start_year = moment(start_time).tz(g_timezoneName).year();
    var end_year   = moment(end_time).tz(g_timezoneName).year();
    for (var i = start_year; i <= end_year; i++) {
      if (i % year_mod !== 0) continue;
      for (var j = 0; j < months.length; j++) {
        var dt = moment.tz(new Date(i, months[j], 1), g_timezoneName); // moment.tz(Date, tz_String) is NOT the same as moment(Date).tz(String) !!
        dt.year(i);
        t = dt.valueOf();
        if (t < start_time || t > end_time) continue;
        ticks.push({ v:t,
                     label: formatter(moment(t).tz(g_timezoneName), granularity, opts, dg)
                   });
      }
    }
  }

  return ticks;
};

Finally, we have to tell Dygraph to use this ticker and formatters, by adding them to the options object like this:

var graphoptions = {
  labels: ['Time', 'Impressions', 'Clicks'],
  axes: {
    x: {
      valueFormatter: valueFormatter,
      axisLabelFormatter: dateAxisFormatter,
      ticker: customDateTickerTZ
    }
  }
};
g = new Dygraph(chart, data, graphoptions);

If you want to change the timezone and then refresh the graph, do this:

g_timezoneName = "<a new timezone name that you've configured moment-timezone to use>";
g.updateOptions({axes: {x: {valueFormatter: valueFormatter, axisLabelFormatter: dateAxisFormatter, ticker: customDateTickerTZ}}});

These code snippets were tested with Dygraphs.VERSION 1.0.1, moment.version 2.7.0, and moment.tz.version 0.0.6.

like image 107
Eddified Avatar answered Mar 27 '23 18:03

Eddified


It's a bit late, but Eddified's answer no longer works with the current version of dygraph.js, on the upside there is now an option to use UTC times: labelsUTC: true example is provided here:

  var data = (function() {
        var rand10 = function () { return Math.round(10 * Math.random()); };
        var a = []
        for (var y = 2009, m = 6, d = 23, hh = 18, n=0; n < 72; n++) {
          a.push([new Date(Date.UTC(y, m, d, hh + n, 0, 0)), rand10()]);
        }
        return a;
      })();
      gloc = new Dygraph(
                   document.getElementById("div_loc"),
                   data,
                   {
                     labels: ['local time', 'random']
                   }
                 );
      gutc = new Dygraph(
                   document.getElementById("div_utc"),
                   data,
                   {
                     labelsUTC: true,
                     labels: ['UTC', 'random']
                   }
                 );

http://dygraphs.com/tests/labelsDateUTC.html

like image 27
Jonno_FTW Avatar answered Mar 27 '23 18:03

Jonno_FTW