API Docs for: 0.0.1
Show:

File: lib/graphs/timeline.js

/*
* niViz -- snow profiles visualization
* Copyright (C) 2015 WSL/SLF - Fluelastrasse 11 - 7260 Davos Dorf - Switzerland.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

(function (niviz, moment) {
  'use strict';

  // --- Module Dependencies ---
  var defprops = Object.defineProperties;
  var Graph    = niviz.Graph;
  var Config   = niviz.Config;
  var Common   = niviz.Common;
  var Feature  = niviz.Feature;
  var Gradient = niviz.Gradient;
  var Axis     = niviz.Axis;
  var round    = niviz.util.round;
  var header   = niviz.Header;
  var hsgrid   = niviz.Grid.hsgrid;
  var t        = niviz.Translate.gettext;
  var extend   = niviz.util.extend;

  /** @module niviz */

  /**
   * Visualization of a time line of snow profiles.
   *
   * @class Timeline
   * @constructor
   * @extends Rangegraph
   *
   * @param {Station} data
   * @param {Object} canvas A svg element that will be used as SnapSVG paper
   * @param {Object} properties
   */
  function Timeline (data, canvas, properties) {
    Timeline.uber.call(this, data, canvas, properties);

    extend(this.properties, Timeline.defaults.values);
    this.setProperties(properties);

    this.draw();
  }

  Graph.implement(Timeline, 'Timeline', 'timeline', 'Rangegraph');

  /**
   * A list of parameters that are available for display.
   *
   * @property parameters
   * @type Array<String>
   * @static
   */
  Timeline.parameters = [
    'temperature', 'grainshape', 'grainsize', 'density',
    'wetness', 'sphericity', 'dendricity'
  ];

  Timeline.defaults = new Config('Timeline', [
    { name: 'timeline', type: 'timelineparams',
      values: [ 'bluemultired', 'whitemultiblue', 'bluered', 'whiteblue', 'whiteblack' ],
      default: [
        { name: 'temperature', min: -20, max: 0, steps: 5,
          palette: 'bluemultired', editable: false },
        { name: 'grainshape', color: 'own', gradient: 'grainshape',
          hide: true, editable: false },
        { name: 'grainsize', min: 0, max: 4, steps: 5,
          palette: 'whitemultiblue', editable: false },
        { name: 'density', min: 0, max: 600, steps: 4,
          palette: 'whitemultiblue', editable: false },
        { name: 'wetness', min: 0, max: 4, steps: 5,
          palette: 'whitemultiblue', editable: false },
        { name: 'sphericity', min: 0, max: 1, steps: 5, palette: 'bluered', editable: false },
        { name: 'dendricity', min: 0, max: 1, steps: 5, palette: 'whiteblue', editable: false }
      ]
    }
  ]);

  Timeline.defaults.load();

  Timeline.get = function (name) {
    var i, ii = Timeline.defaults.values.timeline.length;

    for (i = 0; i < ii; ++i) {
      if (Timeline.defaults.values.timeline[i].name === name)
        return Timeline.defaults.values.timeline[i];
    }

    return null;
  };

  /**
   * Remove a parameter from the timeline Config Timeline.defaults. This method is
   * invoked by the settings modal when a user removes a parameter from the pro parser
   * parameters section.
   *
   * @method remove
   * @static
   * @param {String} name Paramter name (e. g. ramm, density)
   */
  Timeline.remove = function (name) {
    var i, ii = Timeline.defaults.timeline.length - 1, current;

    for (i = ii; i >= 0; --i) {
      current = Timeline.defaults.timeline[i].name;
      if (current === name || current === '') Timeline.defaults.timeline.splice(i, 1);
    }
  };

  defprops(Timeline.prototype, {
    /**
     * Get all selectable parameters.
     *
     * @property parameters
     * @type Array<Object>
     */
    parameters: {
      get: function () {
        return Timeline.defaults.values.timeline.map(function (value) {
          if (Feature.type[value.name])
            return {lookup: value.name, name: t(Feature.type[value.name].name)};
        });
      }
    }
  });

  /**
   * Overwrite current properties with the ones passed as parameter. If a new
   * parameter is set to display: emit the 'parameter' event and retrieve the
   * gradient and color settings for the parameter.
   *
   * @method setProperties
   * @param {Object} properties
   */
  Timeline.prototype.setProperties = function (properties) {
    if (this.properties.parameter !== properties.parameter)
      this.data.emitter.emit('parameter', properties.parameter);

    extend(this.properties, properties);
  };

  /**
   * Setup the dimensions of the underlying Rangegraph and draw header,
   * horizontal grid and legend.
   *
   * @method setup
   * @private
   * @chainable
   */
  Timeline.prototype.setup = function () {
    var p = this.properties, pconfig = Timeline.get(p.parameter);

    if (!pconfig) { // Revert to temperature if selected parameter doesn't exist anymore
      p.parameter = 'temperature';
      pconfig = Timeline.get(p.parameter);
    }

    this.remove('hs', this.elements);

    p.margin = 20;
    p.unit = 5;

    p.tl.x = p.tl.x + 1.5 * p.fontsize + 2 * p.margin;
    p.br.x = p.br.x - 2 * p.fontsize;
    p.length.x = p.br.x - p.tl.x;

    this.gradient = new Gradient(pconfig);

    return this.reconfigure().header().legend();
  };

  /**
   * Reconfigure gridy, i.e. snow height axis, according to zoom area and draw it
   *
   * @method reconfigure
   * @private
   * @chainable
   */
  Timeline.prototype.reconfigure = function () {
    var p = this.properties;

    this.hsgrid = hsgrid(this.data, null, null, Common.defaults.autoscale);
    if (p.hsmin !== undefined || p.hsmax !== undefined) { // hsmin, hsmax may be set by the user
      var max = (p.hsmax || p.hsmax === 0) ? p.hsmax : this.hsgrid.max,
          min = (p.hsmin || p.hsmin === 0) ? p.hsmin : this.hsgrid.min;
      this.hsgrid = niviz.Grid.hsfixed(min, max);
    }

    // Code with vertical zoom:
    var r = Math.abs(this.hsgrid.min - this.hsgrid.max);
    var max = this.hsgrid.max -  r * this.yzoom.min / 100;
    var min = this.hsgrid.max - r * this.yzoom.max / 100;

    this.hsgrid = hsgrid({bottom: min, top: max}, null, null, true);
    this.axisy = new Axis(min, max, p.br.y, p.tl.y);
    // END code for vertical zoom

    return this.gridy();
  };

  /**
   * Draw the grain shape legend and hook it up with the mousemove event. Hovering
   * on a grain shape color will display a popup with a description of the grain shape.
   *
   * @method gtlegend
   * @private
   */
  Timeline.prototype.gtlegend = function (set) {
    var paper = this.paper, p = this.properties, gradient = this.gradient.gradient,
      self = this, rect = paper.rect(p.tl.x - 2 * p.margin, p.tl.y, p.margin, p.length.y)
        .attr({ fill: 'l(1,1,1,0)' + gradient.gradient, strokeWidth: 1, stroke: p.grid_color })
        .transform('t0.5,0.5'), top, i, mfcr = paper.g(),
        inset = p.tl.y + p.length.y - p.length.y / 10 / 2 - p.fontsize / 2 - 2;

    var x, y = Math.ceil(p.tl.y + p.length.y/11 * 7);

    for (i = 1; i < 4; ++i) {
      x = Math.round(p.tl.x - p.margin - i * 5 + 1);
      mfcr.add(paper.line(x, y, x, Math.ceil(y + p.length.y / 11))
        .attr({stroke: p.grid_color, strokeWidth: 1.5, pointerEvents: 'none', strokeOpacity: 0.6}));
    }

    rect.mousemove(function (e) {
      clearTimeout(self.legendtimer);
      self.legendtimer = setTimeout(function () {
        top = self.canvas.offset().top + p.tl.y;
        var index = 10 - Math.min(Math.floor(Math.abs(e.pageY - top) / p.length.y * 11), 10);
        if (self.popupindex === index) return;

        self.popupindex = index;
        if (self.popup) self.popup.remove();
        self.popup = self.paper.g();

        var lbl = paper.text(0, p.fontsize, t(gradient.lbls[index], 'grainshape'))
            .attr(p.font).attr({ textAnchor: 'start' }),
            bbox = lbl.getBBox();

        var box = self.paper.rect(bbox.x - 5, bbox.y - 7, bbox.width + 10,
                                  bbox.height + 14).attr({
                                    stroke: p.grid_color,
                                    fill: 'white',
                                    fillOpacity: 0.7
                                  });

        self.popup.add(box);
        self.popup.add(lbl);
        self.popup[1].transform('t12,0');
        self.popup[0].transform('t12,0');
        self.popup.transform('t' + (p.tl.x - p.margin + 2) + ','
                             + (inset - index * p.length.y / 11) + ' s0', lbl);
        self.popup.animate({
          transform: 't' + (p.tl.x - p.margin + 2) + ','
            + (inset - index * p.length.y / 11) + ' s1'
        }, 100, mina.easein);
      }, 10);
    });

    rect.mouseout(function () {
      self.popupindex = null;
      clearTimeout(self.legendtimer);
      if (self.popup) self.popup.remove();
    });

    set.add(rect);
    set.add(mfcr);
  };

  /**
   * Draw the legend on the left side of the graph. The legend consists of a bar filled
   * with the color gradient for the current parameter and a few labels to denote important
   * values on the bar.
   *
   * @method legend
   * @private
   * @chainable
   */
  Timeline.prototype.legend = function () {
    var paper = this.paper, p = this.properties, gradient = this.gradient.gradient,
      set = paper.g(), mfcrpos;

    this.remove('legend', this.elements);
    if (!gradient) return this;

    var path = paper.g(), i, ii, font = extend({}, p.font);

    if (p.parameter !== 'grainshape') {
      set.add(
        paper.rect(p.tl.x - 2 * p.margin, p.tl.y, p.margin, p.length.y)
          .attr({ 'fill': 'l(1,1,1,0)' + gradient.gradient,
                  strokeWidth: 1, stroke: p.grid_color })
          .transform('t0.5,0.5')
      );
    } else {
      font.fontSize = '18px';
      font.fontFamily = 'snowsymbolsiacs';
      if (p.length.y < 250) font.fontSize = '14px';

      this.gtlegend(set); // Fix issue 450 here
    }

    for (i = 0, ii = gradient.labels.length; i < ii; ++i) {
      var left = p.tl.x - 2 * p.margin - p.unit;
      if (gradient.labels[i] === 'O') left = p.tl.x - 2 * p.margin - p.unit - font.fontSize.replace('px', '');

      set.add(
        paper.text(left,
                   p.br.y - p.length.y * gradient.positions[i] + p.fontsize / 2 - 2,
                   gradient.labels[i] + '')
          .attr(font).attr({'text-anchor': 'end'}).attr({ opacity: 0.9 })
      );

      //The gutter
      if (gradient.nogutter) continue;

      if (i > 0 && i < (ii - 1)) {
        var y = p.br.y - p.length.y * gradient.positions[i];
        path.add(paper.line(p.tl.x - 2 * p.margin, y, p.tl.x - 2 * p.margin + p.unit, y));
      }
    }

    var config = Feature.type[p.parameter];
    var unit = config.unit ? ' [' + config.unit + ']' : '';
    set.add(
      paper.text(p.fontsize * 1.5, p.br.y - p.length.y / 2, t(config.name) + unit)
        .attr(p.font).attr({ opacity: 0.9, 'text-anchor': 'middle' })
        .transform('r270,' + (p.fontsize * 1.5) + ',' + (p.br.y - p.length.y / 2))
    );

    set.add(path
      .attr({stroke: p.grid_color})
      .transform('t0.5,0.5')); // to make lines thinner

    this.elements['legend'] = set;

    return this;
  };

  /**
   * Draw the ordinate legend and grid for the snow height
   *
   * @method gridy
   * @private
   * @chainable
   */
  Timeline.prototype.gridy = function () {
    var p = this.properties, paper = this.paper, i, ii, path = paper.g();

    this.remove('gridy', this.elements);

    var set = this.elements['gridy'] = paper.g();

    for (i = 0, ii = this.hsgrid.heights.length; i < ii; ++i) {
      var height = round(this.hsgrid.heights[i], 2),
        y = Math.round(this.axisy.pixel(height)) || 0;

      if (i && i !== (ii - 1))
        path.add(paper.line(p.tl.x - 5, y, p.br.x + 5, y));

      if (y > p.br.y || y < p.tl.y) continue;
      set.add(paper.text(p.br.x + 7, y + p.fontsize / 2 - 2, height + '')
              .attr(p.font).attr({ textAnchor: 'start', opacity: 0.9 }));
    }

    var label = (this.data.bottom < 0 ? t('Height') : t('Snow height')) + ' [cm]';
    var x = this.canvas.width() - p.fontsize, y = p.tl.y + p.length.y / 2;
    set.add(paper.text(x, y, label)
            .attr(p.font).attr({ opacity: 0.9, 'text-anchor': 'middle' })
            .transform('r270,' + x + ',' + y));

    set.add(path.attr({
      stroke: p.grid_color,
      strokeOpacity: 0.2,
      strokeDasharray: '2,2',
      pointerEvents: 'none'
    }).transform('t0.5,0.5'));

    return this;
  };

  /**
   * Draw a header at the left top of the graph. The header information is clickable
   * and links to a map revealing the exact location.
   *
   * @method header
   * @private
   * @chainable
   */
  Timeline.prototype.header = function () {
    var p = this.properties, paper = this.paper, meteo = this.data, text1 = '', text2 = '',
      position = meteo.position, avgstep = this.data.avgstep('minutes');

    this.remove('header', this.elements);

    if (meteo.name) text1 += meteo.name;
    if (position) {
      if (position.link) {
        text1 += (' (' + round(position.latitude, 4) + '° N '
                       + round(position.longitude, 4) + '° E)');
      }

      if (position.altitude) text1 += (', ' + position.altitude + position.uom);

      if (position.angle !== '') text2 += (t('Slope') + ': ' + position.angle  + '°');
      if (position.azimuth !== '')
        text2 += (', ' + t('Azimuth') + ': ' + position.azimuth  + '°');
      if (avgstep) text2 +=  (', ' + t('Avg. timestep') + ': ' + avgstep + ' min');
    }

    var el = paper.g().add(paper.text(p.tl.x, p.tl.y - 1.5 * p.fontsize - 2, text1))
                      .add(paper.text(p.tl.x, p.tl.y - .5 * p.fontsize - 2, text2));
    el.attr(p.font).attr({
      textAnchor: 'start',
      opacity: 0.9
    });

    header.link(el, position.link, p.font_color);

    this.elements['header'] = el;
    return this;
  };

  /**
   * Method to be called when mouseout event is detected. Delete the currently
   * displayed date label (unhighlight) and display the date label for the
   * current indicator position, if the indicator is set.
   *
   * @method mouseout
   * @protected
   */
  Timeline.prototype.mouseout = function () {
    this.unhighlight();

    if (this.clicked) { // If there is an indicator show the date
      this.emit('mousemove', this.data.current.date);
      this.highlight(this.data.current.index, true);
    }
  };

  /**
   * Method to be called when mouse events are turned off.
   *
   * @method mouseout
   * @protected
   */
  Timeline.prototype.mouseoff = function () {
    clearTimeout(this.timer);
  };

  /**
   * Method to be called when mousemove event is detected. It calls the method
   * Timeline:highlight in turn, which displays the date label.
   *
   * @method mousemove
   * @private
   * @param {Object} e Mousemove DOM event object
   * @param {Object} offset canvas offset object
   */
  Timeline.prototype.mousemove = function (currentdate, e, offset) {
    var axisy = this.axisy, self = this, current;

    clearTimeout(this.timer);
    this.timer = setTimeout(function () {
      if (e) self.hs(Math.round(axisy.coord(e.pageY - self.canvas.offset().top)));
      self.startdragdate = moment.utc(currentdate);

      current = self.data.index(currentdate);
      self.highlight(current, self.clicked);
    }, 5);
  };

  /**
   * Display the snow height at the current cursor position.
   *
   * @method hs
   * @protected
   * @param {Number} snowheight The height of snow in cm
   */
  Timeline.prototype.hs = function (snowheight) {
    var element = this.elements['hs'], text = snowheight + ' cm';

    if (element) {
      element.attr('text', text);
    } else {
      var p = this.properties,
          label = this.paper.text(p.br.x, p.tl.y - p.fontsize - 2, text)
                            .attr(p.font).attr('text-anchor', 'end');

      this.elements['hs'] = label;
    }
  };

  /**
   * Draw the indicator line in case the user desires to lock onto a certain profile.
   *
   * @method draw_indicator
   * @private
   * @param {Number} current Index of profile in this.data.profiles
   */
  Timeline.prototype.draw_indicator = function (current) {
    var p = this.properties;

    this.remove('indicator');

    if (current === undefined
        || (current < this.$indices.start || current > this.$indices.end)) {
      this.clicked = false;
      return;
    }

    var currentdate = moment.utc(this.data.profiles[current].date), pos;
    //if (this.data.length > 1) currentdate.add(this.data.avgstep('minutes') / 2, 'minutes');
    pos = this.coordx(currentdate);

    if (pos < p.tl.x) pos = p.tl.x;
    if (pos > p.br.x) pos = p.br.x;

    this.indicator = this.paper.line(pos, p.tl.y, pos, p.br.y).transform('t0.5,0.5');
    this.indicator.attr({ stroke: '#000' });

    this.emit('mousemove', currentdate);
  };

  /**
   * In case an indicator is present, move one profile to the right.
   * @method next
   */
  Timeline.prototype.next = function () {
    if (!this.data.current || !this.indicator) return;

    var current = this.data.current.index + 1;
    if (current <= this.$indices.end) {
      this.draw_indicator(current);
      this.highlight(current);
    }
  };

  /**
   * In case an indicator is present, move one profile to the left.
   * @method previous
   */
  Timeline.prototype.previous = function () {
    if (!this.data.current || !this.indicator) return;

    var current = this.data.current.index - 1;
    if (current >= this.$indices.start) {
      this.draw_indicator(current);
      this.highlight(current);
    }
  };

  /**
   * Method to be called during dragging on top of cover.
   *
   * @method drag
   * @protected
   * @param {Moment} Date at current drag position
   */
  Timeline.prototype.drag = function (current) {
    current = this.data.index(current);
    this.highlight(current, true);
  };

  /**
   * Method to be called at the start of dragging on top of cover.
   *
   * @method dragstart
   * @protected
   * @param {Moment} current Date at current drag position
   */
  Timeline.prototype.dragstart = function (current) {
    this.startdragdate = moment.utc(current);
  };

  /**
   * Method to be called at the end of dragging on top of cover.
   *
   * @method dragend
   * @protected
   * @param {Moment} current Date at current drag position
   * @param {Number} ms Duration of drag in ms
   */
  Timeline.prototype.dragend = function (current, ms) {
    var startdate, enddate;

    if (ms > 200 || ms === undefined) { // click at least 200ms long
      startdate = moment.utc(this.startdragdate);
      enddate = moment.utc(current);

      if (enddate.isBefore(startdate)) enddate = [startdate, startdate = enddate][0];
      this.clip(startdate, enddate);
      this.unhighlight();

      // removes the indicator if set and not in zoomed range
      this.draw_indicator(this.clicked ? this.data.current.index : undefined);
    } else {
      this.clicked = !this.clicked;

      if (this.clicked) {
        current = this.data.index(this.startdragdate);
        this.draw_indicator(current);
        this.highlight(current);
      } else {
        this.draw_indicator(); // removes the indicator if set
      }
    }
  };

  /**
   * Highlight the current cursor position by drawing the date label and
   * displaying the current snow height.
   *
   * @method highlight
   * @private
   * @param {Number} current Index in this.data.profiles
   * @param {Boolean} noemit Whether to emit the 'profile' event
   */
  Timeline.prototype.highlight = function (current, noemit) {
    var profile = this.data.profiles[current], self = this;

    this.datelabel(profile.date);

    setTimeout(function () {
      if (!noemit && (!self.data.current || current !== self.data.current.index)) {
        self.data.emitter.emit('profile', profile);
        self.data.current = profile;
        self.data.current.index = current;
      }
    }, 5);

  };

  /**
   * Remove all highlights (date and snow height labels).
   * @method unhighlight
   * @private
   */
  Timeline.prototype.unhighlight = function () {
    this.datelabel();
    this.remove('hs', this.elements);
  };

  /**
   * Show a part (or all) of the data within a start and an end date.
   *
   * @method clip
   * @private
   * @param {Moment} start Start date
   * @param {Moment} end End date
   */
  Timeline.prototype.clip = function (start, end) {
    this.reconfigure(); // set snow height axis according to zoom

    if (start && end) {
      this.$range = { start: moment.utc(start), end: moment.utc(end) };
    } else if (this.$range) {
      start = this.$range.start;
      end = this.$range.end;
    }

    this.range(start, end);
    this.indices(start, end);
  };

  /**
   * Show all the data from start to end and remove the 'Show all' button.
   * @method reset
   * @private
   */
  Timeline.prototype.reset = function () {
    this.yzoom = { min: 0, max: 100 };
    this.config();
    this.setup();

    this.emit('dragstart', this.data.profiles[0].date);
    this.emit('dragend', moment.utc(this.data.profiles[this.data.length - 1].date)
                               .add(this.data.avgstep('minutes'), 'minutes'));

    if (this.data && this.data.length)
      this.clip(this.data.profiles[0].date,
                moment.utc(this.data.profiles[this.data.length - 1].date)
                .add(this.data.avgstep('minutes'), 'minutes'));

    this.draw_indicator();
    this.resetbutton(false);
  };

  /**
   * Given a start and end index (for this.data.profiles) draw the current parameter.
   * In case the start index is not 0 and the end index is not the last index show
   * the resetbutton.
   *
   * @method indices
   * @private
   * @param {Moment} startdate Start date of range
   * @param {Moment} enddate End date of range
   */
  Timeline.prototype.indices = function (startdate, enddate) {
    var start = this.data.index(startdate), end = this.data.index(enddate);
    this.resetbutton(start !== 0 || end !== this.data.length - 1, this.reset);

    if (this.data.profiles[start].date.isAfter(this.$range.start)) start--;
    if (this.data.profiles[end].date.isAfter(this.$range.end)) end--;

    this.$indices = { start: Math.max(start, 0), end: Math.max(end, 0) };
    this.drawPNG();
  };

  /**
   * Draw the PNG image for the currently selected parameter. The method creates a canvas
   * element to use as drawing board. Once the graph is drawn onto the canvas, the pastePNG
   * method is called which tranforms the image into a base64 encoded dataURI and embeds it
   * into an SVG image tag.
   *
   * @method drawPNG
   * @private
   */
  Timeline.prototype.drawPNG = function () {
    var p = this.properties, profiles = this.data.profiles, start = this.$indices.start,
      end = this.$indices.end, range = end - start + 1, increment =  p.length.x / range,
      ii, canvas = document.createElement('canvas'), ctx = canvas.getContext('2d'),
      gradient = this.gradient;

    canvas.width  = p.length.x;
    canvas.height = p.length.y;

    if (start < 0 || end < 0) {
      this.pastePNG(p, canvas);
      canvas = null;
      return;
    }

    var coord = { x: this.coordx(profiles[start].date), y: this.coordy(this.data.bottom) };
    var oldx = -1;

    var width = Math.ceil(increment);
    var step = this.coordx(
      moment.utc(profiles[start].date).add(this.data.avgstep('minutes'), 'minutes')
    );
    width = Math.ceil(Math.ceil(step) - Math.floor(coord.x)) + 1;

    for (ii = start; ii <= end; ++ii) {
      var current = profiles[ii], parameter = current[p.parameter];

      if (parameter) {
        coord.x = this.coordx(current.date);
        if (oldx === coord.x) continue;
        oldx = coord.x;

        var layers = parameter.layers.length, jj, newy;

        coord.y = p.br.y - p.tl.y;
        for (jj = 0; jj < layers; ++jj) {
          var layer = parameter.layers[jj];
          this.setFillStyle(ctx, layer);
          newy = this.coordy(layer.top);
          coord.y = this.coordy(layer.bottom);
          ctx.fillRect(coord.x - p.tl.x, round(newy - p.tl.y, 0),
                       width, Math.max(coord.y - newy, 2));
        }

        if (p.parameter === 'grainshape' && current.sh) { //for legacy PRO files
          ctx.fillStyle = '#ff00ff';
          ctx.fillRect(coord.x - p.tl.x, round(newy - 2 - p.tl.y, 0), width, 2);
        }
      }
    }

    this.pastePNG(p, canvas);
    canvas = null;
  };

  Timeline.prototype.setFillStyle = function (ctx, layer) {
    if (layer.value.primary === 'MFcr') {
      ctx.fillStyle = ctx.createPattern(Gradient.patternMFcr, 'repeat');
    } else {
      ctx.fillStyle = this.gradient.color(layer);
    }
  };

  /**
   * Converts the image on the canvas into a base64 encoded dataURI and embeds it in
   * a SVG image tag.
   *
   * @method pastePNG
   * @private
   */
  Timeline.prototype.pastePNG = function (p, canvas) {
    var image = this.elements['image'];

    if (!image) {
      this.elements['image'] = this.paper.image(canvas.toDataURL('image/png'),
                                                p.tl.x, p.tl.y, p.length.x, p.length.y);
      this.elements['image'].prependTo(this.paper);
    } else {
      image.attr({ href: canvas.toDataURL('image/png'), x: p.tl.x, y: p.tl.y,
                   width: p.length.x, height: p.length.y });
    }
  };

  /**
   * Get the rounded x-coordinate for a given date.
   *
   * @method coordx
   * @private
   * @param {Moment} date
   * @return {Number} x-coordinate
   */
  Timeline.prototype.coordx = function (date) {
    return Math.round(this.axisx.pixel(date.format('X')));
  };

  /**
   * Get the rounded y-coordinate for a given (snow) height.
   *
   * @method coordy
   * @private
   * @param {Number} height
   * @return {Number} y-coordinate
   */
  Timeline.prototype.coordy = function (height) {
    return Math.round(this.axisy.pixel(height));
  };

  /**
   * Set the date to display explicitly (may be used externally)
   * @method setDate
   */
  Timeline.prototype.setDate = function (date) {
    this.hs(0);
    var current = this.data.index(date);
    this.highlight(current, this.clicked);
  };

  /**
   * Draw the Timeline and set up the mouse events.
   * @method draw
   */
  Timeline.prototype.draw = function () {
    this.config();
    this.setup();

    this.mouseon(false);

    if (!this.$range) {
      this.reset();
    } else {
      this.clip();
    }

    if (this.clicked) this.draw_indicator(this.data.current.index);

    this.mouseon(true);
    this.draggable(this.cover);
  };

  // --- Helpers ---

  // --- Module Exports ---
  niviz.Timeline = Timeline;

}(niviz, moment));