API Docs for: 0.0.1
Show:

File: lib/graphs/rangegraph.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 Graph        = niviz.Graph;
  var Config       = niviz.Config;
  var Axis         = niviz.Axis;
  var EventEmitter = niviz.EventEmitter;
  var extend       = niviz.util.extend;
  var nivizNode    = niviz.util.nivizNode;

  /** @module niviz */

  /**
   * The Rangegraph class provides the basic visual means to display time
   * series of data (e. g. snow profiles or meteo data). The class focuses
   * on the display of the dates along the abscissa and provides methods
   * to easily change the range of the dates to be displayed. No interactivity
   * is provided by the Rangegraph - the children of the Rangegraph class
   * shall deal with this issue.
   *
   * @class Rangegraph
   * @extends Graph
   * @constructor
   *
   * @param {Object} data Time series of data
   * @param {Object} canvas A svg element that will be used as SnapSVG paper
   * @param {Object} properties
   */
  function Rangegraph (data, canvas, properties) {
    Rangegraph.uber.call(this, data, canvas, properties);

    EventEmitter.call(this);

    if (canvas.jquery) {
      this.canvasnode = canvas[0];
      this.canvas = canvas;
    } else {
      this.canvasnode = canvas;
      this.canvas = nivizNode(canvas);
    }

    this.canvas.empty();

    this.data = data;
    console.dir(this.data);

    this.elements = {};
    this.yzoom = { min: 0, max: 100 }; // in percent vertical zoom window

    this.properties = extend({}, Rangegraph.defaults.values);
    this.setProperties(properties);
  }

  Graph.implement(Rangegraph, 'Rangegraph', 'rangegraph');

  Rangegraph.defaults = new Config('Rangegraph', [
    { name: 'fontsize',   type: 'number', default: 14 },
    { name: 'grid_color', type: 'color',  default: '#000' }
  ]);

  Rangegraph.defaults.load();

  /**
   * Overwrite current properties with the ones passed as parameter.
   *
   * @method setProperties
   * @param {Object} properties
   */
  Rangegraph.prototype.setProperties = function (properties) {
    extend(this.properties, properties);
  };

  /**
   * Check if the object with identifier name exists in context ctx and
   * try to remove it from the paper and then delete the object itself.
   *
   * @method remove
   * @protected
   * @param {Object} ctx Context
   * @param {String} name Object identifier
   */
  Rangegraph.prototype.remove = function (name, ctx) {
    ctx = ctx || this;
    if (ctx[name] && ctx[name].remove) ctx[name].remove();
    delete ctx[name];
  };

  /**
   * Check if this instance has a valid SnapSVG paper object, in case
   * the size of the canvas element has changed or no paper object is present
   * resize or create the paper object.
   *
   * @method getpaper
   * @private
   */
  Rangegraph.prototype.getpaper = function () {
    var p = this.properties, oldheight = p.height, oldwidth = p.width;

    p.height = this.canvas.height();
    p.width  = this.canvas.width();

    if (!this.paper || p.height !== oldheight || p.width !== oldwidth) {
      if (this.paper) {
        this.paper.attr({
          width: p.width + 'px',
          height: p.height + 'px'
        });
      } else {
        this.paper = Snap(this.canvasnode);
      }
    } else {
      console.log('Not resizing canvas/svg');
    }
  };

  /**
   * Configure basic properties of the Rangegraph such as font, margins, height and width
   * @method config
   * @protected
   */
  Rangegraph.prototype.config = function () {
    var p = this.properties;

    this.getpaper();

    p.font = {
      fontSize: p.fontsize + 'px',
      textAnchor: 'middle',
      fontFamily: 'LatoWeb, Helvetica, Arial'
    };

    p.space = 3 * p.fontsize;
    p.tl = { x: p.space, y: p.space };
    p.br = { x: this.canvas.width() - p.space, y: this.canvas.height() - p.space };
    p.length = { x: p.br.x - p.tl.x, y: p.br.y - p.tl.y};
  };

  /**
   * Synchronize this graph with another rangegraph derivate by
   * registering for all the relevant events.
   *
   * @method synchronize
   * @protected
   * @param {Rangegraph} graph
   */
  Rangegraph.prototype.synchronize = function (graph) {
    var self = this;

    graph.on('mousemove', function (current) {
      self.mousemove(current);
    });

    graph.on('mouseout', function () {
      self.mouseout();
    });

    graph.on('dragstart', function (current) {
      self.dragstart(current);
    });

    graph.on('drag', function (current) {
      self.drag(current);
    });

    graph.on('dragend', function (current, ms) {
      self.dragend(current, ms);
    });
  };

  /**
   * Desynchronize this graph with another rangegraph derivate by
   * registering for all the relevant events.
   *
   * @method desynchronize
   * @protected
   * @param {Rangegraph} graph
   */
  Rangegraph.prototype.desynchronize = function (graph) {
    graph.off();
  };

  /**
   * This method enables/disables the mousemove event on the area spanning
   * the graph. Children may implement the methods mouseout, mousemove and
   * mouseoff to tap into this event.
   *
   * @method mouseon
   * @protected
   * @param {Boolean} on Whether to turn the mousemove event on or off
   */
  Rangegraph.prototype.mouseon = function (on) {
    var self = this, canvas = this.canvas, current;

    if (on) {
      this.cover.mousemove(function (e) {
        current = moment.unix(self.axisx.coord(e.pageX - canvas.offset().left));
        self.emit('mousemove', current);
        self.mousemove(current, e);
      });

      this.cover.mouseout(function () {
        clearTimeout(self.timer);
        setTimeout( function () {
          self.emit('mouseout');
          self.mouseout();
        }, 15);
      });

    } else {
      self.emit('mouseoff');
      self.mouseoff();

      if (self.cover) self.cover.unmouseout();
      if (self.cover) self.cover.unmousemove();

    }
  };

  /**
   * Method to be called when mousemove event is detected.
   *
   * @method mousemove
   * @protected
   * @param {Object} e Mousemove DOM event object
   * @param {Object} offset canvas offset object
   */
  Rangegraph.prototype.mousemove = function (e, offset) {
    console.log('Nothing implemented here');
  };

  /**
   * Method to be called when mouseout event is detected.
   *
   * @method mouseout
   * @protected
   */
  Rangegraph.prototype.mouseout = function () {
    console.log('Nothing implemented here');
  };

  /**
   * Method to be called when mouse events are turned off.
   * Use this to implement cleanup routines.
   *
   * @method mouseout
   * @protected
   */
  Rangegraph.prototype.mouseoff = function () {
    console.log('Nothing implemented here');
  };

  /**
   * Method to be called during dragging on top of cover.
   *
   * @method drag
   * @protected
   * @param {Moment} current Date at current drag position
   */
  Rangegraph.prototype.drag = function (current) {
    console.log('Nothing implemented here');
  };

  /**
   * Method to be called at the start of dragging on top of cover.
   *
   * @method dragstart
   * @protected
   * @param {Moment} current Date at current drag position
   */
  Rangegraph.prototype.dragstart = function (current) {
    console.log('Nothing implemented here');
  };

  /**
   * 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
   */
  Rangegraph.prototype.dragend = function (current, ms) {
    console.log('Nothing implemented here');
  };

  /**
   * Drag functions used for zooming when mouse goes down over this.cover,
   * start drawing selection box.
   *
   * @method draggable
   * @private
   * @param {Element} element SnapSVG element to set up with drag functionality
   */
  Rangegraph.prototype.draggable = function (element) {
    var self = this, lastdx = 0, lastdy = 0, p = this.properties,
      left = this.canvas.offset().left, top = this.canvas.offset().top,
      start = { x: left + p.tl.x, y: top + p.tl.y },
      end = { x: start.x + p.length.x, y: start.y + p.length.y },
      offset = { x: self.canvas.offset().left, y: self.canvas.offset().top },
      clickstart, current, startpos = { x: null, y: null },
      endpos = { x: null, y: null };

    element.undrag(); // cleanup
    element.drag(

      function (dx, dy, x, y, event) { //mousedrag
        var my = this;
        this.dragtimer = setTimeout( function () {
          if (lastdx === dx && lastdy === dy) return;
          lastdx = dx;
          lastdy = dy;

          if ((x >= start.x && x <= end.x) && (y >= start.y && y <= end.y)) {
            my.box.transform('T' + Math.min(0, dx) + ', ' + Math.min(0, dy));
            my.box.attr('width', Math.abs(dx));
            my.box.attr('height', Math.abs(dy));

            endpos.x = x + 1 - offset.x;
            endpos.y = y + 1 - offset.y;
          } else if (x >= start.x && x <= end.x) {
            if (y < start.y) {
              my.box.transform('T' + Math.min(0, dx) + ', ' + (p.tl.y - startpos.y + 2));
              my.box.attr('width', Math.abs(dx));
              my.box.attr('height', Math.abs(p.tl.y - startpos.y + 2));
              endpos.y = p.tl.y;
            } else {
              my.box.transform('T' + Math.min(0, dx) + ', 0');
              my.box.attr('width', Math.abs(dx));
              my.box.attr('height', Math.abs(p.br.y - startpos.y + 1));
              endpos.y = p.tl.y + p.length.y;
            }
            endpos.x = x + 1 - offset.x;
          } else if (y >= start.y && y <= end.y) {
            if (x < start.x) {
              my.box.transform('T' + (p.tl.x - startpos.x + 2) + ', ' + Math.min(0, dy));
              my.box.attr('width', Math.abs(p.tl.x - startpos.x + 2));
              my.box.attr('height', Math.abs(dy));
              endpos.x = p.tl.x;
            } else {
              my.box.transform('T0, ' + Math.min(0, dy));
              my.box.attr('height', Math.abs(dy));
              endpos.x = p.tl.x + p.length.x;
            }
            endpos.y = y + 1 - offset.y;
          }
          // else if (x > end.x) {
          //   endpos.x = p.tl.x + p.length.x;
          //   my.box.attr('width', Math.abs(p.br.x - startpos.x));
          // } else if (x < start.x) {
          //   endpos.x = p.tl.x;
          // }

          // if (y >= start.y && y <= end.y) {
          //   // my.box.transform('T0' + Math.min(0, dy));
          //   // my.box.attr('height', Math.abs(dy));
          //   //
          //   // console.log('1');
          // } else if (y > end.y) {
          //   endpos.y = p.tl.y + p.length.y;
          //   my.box.attr('height', Math.abs(p.br.y - startpos.y));
          //   console.log('2');
          // } else if (y < start.y) {
          //   endpos.y = p.tl.y;
          //   console.log('3');
          //   my.box.attr('height', Math.abs(startpos.y - p.tl.y));
          // }

          current = moment.unix(self.axisx.coord(endpos.x));
          self.drag(current);
          self.emit('drag', current);
        }, 15);
      },

      function (x, y, event) { //mousestart
        this.box && this.box.remove();
        self.mouseon(false);
        p = self.properties;
        offset.x = self.canvas.offset().left;
        offset.y = self.canvas.offset().top;
        start.x = offset.x + p.tl.x;
        start.y = offset.y + p.tl.y;
        end.x = start.x + p.length.x;
        end.y = start.y + p.length.y;

        clickstart = +new Date();
        lastdx = 0;
        lastdy = 0;

        startpos.x = x + 1 - offset.x; // the +1 is for index calculation
        startpos.y = y + 1 - offset.y;

        current = moment.unix(self.axisx.coord(startpos.x));
        self.dragstart(current);
        self.emit('dragstart', current);

        this.box = self.paper.rect(x - offset.x, y - offset.y, 0, 0)
          .attr({ 'stroke': '#9999FF', 'fill': '#9999FF', 'opacity': 0.3 });
        this.box.node.style['pointer-events'] = 'none';
      },

      function (event) { //mouseend
        var r = Math.abs(self.yzoom.min - self.yzoom.max);
        var min = (startpos.y > endpos.y) ? endpos.y : startpos.y;
        var max = (startpos.y > endpos.y) ? startpos.y : endpos.y;
        var newmax = self.yzoom.min + Math.round((max - p.tl.y) / p.length.y * r);
        var newmin = self.yzoom.min + Math.round((min - p.tl.y) / p.length.y * r);

        if (newmin !== newmax) {
          self.yzoom.max = newmax;
          self.yzoom.min = newmin;
        }

        self.datelabel();
        this.box.remove();
        self.dragend(current, (+new Date()) - clickstart);
        self.emit('dragend', current, (+new Date()) - clickstart);
        self.mouseon(true);
      }
    );
  };

  /**
   * Set the new date range for the abscissa
   *
   * @method range
   * @protected
   * @param {Moment} start Start date of time series
   * @param {Moment} end End date of time series
   */
  Rangegraph.prototype.range = function (start, end) {
    var p = this.properties, pstart = parseInt(start.format('X')),
      pend = parseInt(end.format('X'));

    this.axisx = new Axis(pstart, pend, p.tl.x, p.br.x);

    this.dates = this.griddates(start, end);
    this.dates.labels = this.labelsx(this.dates);
    this.gridx();
  };

  /**
   * Check the size of the date range given.
   *
   * @method mode
   * @private
   * @param {Moment} start Start date of time series
   * @param {Moment} end End date of time series
   * @return {String} Categorization of date range as
   *                  'years', 'months', 'monthweeks' or 'days'
   */
  Rangegraph.prototype.mode = function (start, end) {
    var span = end.diff(start, 'days');

    if (span > 3000) {
      return 'years';
    } else if (span > 300) {
      return 'months';
    } else if (span > 90) {
      return 'monthweeks';
    } else if (span > 3) {
      return 'days';
    }

    return 'hours';
  };

  /**
   * Given an array of dates, create the respective labels to be shown
   *
   * @method labelsx
   * @private
   * @param {Array<Moment>} dates An array of dates as moment objects
   * @return {Array<String>} Array of strings representing the passed dates
   */
  Rangegraph.prototype.labelsx = function (dates) {
    var i, ii = dates.length, labels = [], p = this.properties, last, year, label;

    var mod = Math.ceil((8 * p.fontsize) / (p.width / dates.length));

    for (i = 0; i < ii; ++i) {
      if (dates.mode === 'years') {
        label = year = dates[i].year();
        labels.push({ text: label });
      } else if (dates.mode === 'months') {
        label = (i % mod === 0) ? dates[i].format('MMM') : '';

        if ((label !== '') && (!year || year !== dates[i].year())) {
          label = year = dates[i].year();
        }

        labels.push({ text: label });

      } else if (dates.mode === 'days' || dates.mode === 'monthweeks') {
        label = (i % mod === 0) ? dates[i].format('MMM') + ' ' + dates[i].date() : '';

        if ((!year || year !== dates[i].year()) && label !== '') {
          label = year = dates[i].year();
        }

        labels.push({ text: label });

      } else if (dates.mode === 'hours') {
        var current = dates[i].date();

        if (i % mod === 0) {
          if (current !== last) {
            label = dates[i].format('MMM') + ' ' + dates[i].date();

            if (!year || year !== dates[i].year()) {
              label +=  ' \'' + dates[i].format('YY');
              year = dates[i].year();
            }

            labels.push({ text: label });

          } else {
            labels.push({
              text: dates[i].format('HH:mm')
            });
          }
          last = dates[i].date();
        } else {
          labels.push({
            text: ''
          });
        }
      }
    }

    return labels;
  };

  /**
   * Given a start and an end date return an array of dates that divide up the
   * range between the start and end date into approximately even intervals
   *
   * @method griddates
   * @private
   * @param {Moment} start Start date of time series
   * @param {Moment} end End date of time series
   * @return {Array<Moment>} Array of dates
   */
  Rangegraph.prototype.griddates = function (start, end) {
    var mode = this.mode(start, end), dates = [], span, i, inc, tmp;

    if (mode === 'years') {
      inc = Math.ceil(end.diff(start, 'years') / 30);
      for (i = start.year() + 1; i <= end.year(); i = i + inc) {
        dates.push(moment('2000-01-01 00:00:00.000').year(i));
      }
    } else if (mode === 'months'){
      span = end.diff(start, 'months');
      for (i = 1; i <= span; ++i) {
        tmp = moment(start).date(1).hour(0).minute(0).second(0).millisecond(0).add(i, 'M');
        dates.push(tmp);
      }
    } else if (mode === 'monthweeks'){
      span = end.diff(start, 'weeks');
      var firstweek = 7 - start.day();
      //console.dir(firstweek);
      for (i = 0; i <= span; ++i) {
        tmp = moment(start).hour(0).minute(0).second(0).millisecond(0)
            .add(firstweek, 'd').add(7 * i, 'd');
        dates.push(tmp);
      }
    } else if (mode === 'days'){
      span = end.diff(start, 'days');
      inc = Math.ceil(span / 30);
      for (i = 1; i <= span; i = i + inc) {
        tmp = moment(start).hour(0).minute(0).second(0).millisecond(0).add(i, 'd');
        dates.push(tmp);
      }
    } else if (mode === 'hours'){
      span = end.diff(start, 'hours');
      inc = Math.ceil(span / 30);
      for (i = 1; i <= span; i = i + inc) {
        tmp = moment(start).minute(0).second(0).millisecond(0).add(i, 'h');
        dates.push(tmp);
      }
    }

    dates.mode = mode;

    return dates;
  };

  /**
   * Draw a date label below the abscissa for any given date
   *
   * @method datelabel
   * @protected
   * @param {Moment} date Date to be highlighted on the abscissa
   */
  Rangegraph.prototype.datelabel = function (date) {
    var p = this.properties, paper = this.paper, set = paper.g(), axis = this.axisx,
        el = this.elements['datelabel'];

    if (!date) {
      this.remove('datelabel', this.elements);
      return;
    }

    var start = axis.pixel(date.format('X')), width = 4.5 * p.fontsize, lx = start - width,
        text = date.format("MMM DD 'YY, HH:mm");
    lx = Math.max(axis.pxmin, lx);
    lx = Math.min(axis.pxmax - 2 * width + 1, lx);

    if (!el) {
      set.add(paper.rect(lx, p.br.y, width * 2, p.fontsize * 2).attr({
        fill: '#000',
        strokeWidth: 0
      }));
      set.add(paper.text(lx + width, p.br.y + p.fontsize + 5, text)
               .attr({ fill: '#FFF' }).attr(p.font)
              );

      if (!el) this.elements['datelabel'] = set;
    } else { // transform
      var bbox = el[0].getBBox();
      el[1].attr({text: text});
      if (bbox.x !== lx) {
        el[0].attr({x: lx});
        el[1].attr({x: lx + width});
      }
    }
  };

  /**
   * Draw the coordinate system with dotted lines and a frame around it
   * @method gridx
   * @protected
   */
  Rangegraph.prototype.gridx = function () {
    var p = this.properties, paper = this.paper, set = paper.g(),
        axis = paper.g(), i, x;

    if (this.elements['axisx']) this.elements['axisx'].remove();

    for (i = 0; i < this.dates.length; ++i) {
      x = Math.round(this.axisx.pixel(this.dates[i].format('X')));

      if (this.dates.labels[i].text) {
        axis.add(paper.line(x, p.tl.y, x, p.br.y + 5));
        set.add(paper.text(x + 2, p.br.y + p.fontsize + 5, this.dates.labels[i].text)
                .attr(p.font));
      } else {
        axis.add(paper.line(x, p.tl.y, x, p.br.y));
      }
    }

    set.add(paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y).attr({
      stroke: p.grid_color,
      strokeOpacity: 0.3,
      fill: 'none'
    }).transform('t0.5,0.5'));

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

    set.add(axis);

    this.elements['axisx'] = set;

    if (!this.cover) {
      this.cover = this.paper
        .rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
        .attr({ opacity: 0.0, fill: '#00F' });
      this.cover.appendTo(this.paper);
    } else {
      this.cover.attr({ x: p.tl.x, y: p.tl.y, width: p.length.x, height: p.length.y });
    }
  };

  /**
   * Show or delete the resetbutton
   *
   * @method resetbutton
   * @protected
   * @param {Boolean} show true = show button, false = hide button
   * @param {Function} callback Callback function executed on click
   */
  Rangegraph.prototype.resetbutton = function (show, callback) {
    var paper = this.paper, p = this.properties, self = this;

    this.remove('showall', this.elements);
    //if (show && this.elements['showall']) return;

    if (show) {
      var set = paper.g(), width = 4 * p.fontsize, lines = paper.g();
      var center = { x: p.br.x - 1.6 * width, y: p.tl.y + p.fontsize };
      var rect = paper.rect(p.br.x - 2 * width, p.tl.y, 2 * width, 2 * p.fontsize)
          .attr({ fill: '#000', 'stroke-width': 0, opacity: 0.0 });

      set.add(rect);

      // Magnifying glass:
      set.add(paper.circle(center.x, center.y, 7.5).attr({
        strokeWidth: 1.5, stroke: p.grid_color, fill: 'none', class: 'removable'
      }));

      lines.add(paper.line(center.x - 3, center.y, center.x + 3, center.y));
      lines.add(paper.line(center.x + 5, center.y + 5, center.x + 10, center.y + 10));
      set.add(lines.attr({
        'stroke-width': 1.5, stroke: p.grid_color, class: 'removable'
      }));

      set.add(paper.text(p.br.x - 0.8 * width, center.y + p.fontsize / 2 - 2, 'Show all')
              .attr(p.font).attr({ opacity: 0.9, class: 'removable' }));

      set.attr('cursor', 'pointer');
      set.hover(function () { rect.attr({ opacity: 0.1 }); },
                function () { rect.attr({ opacity: 0.0 }); });

      set.click(function () {
        callback.apply(self);
      });

      this.elements['showall'] = set;
    } else {
      this.remove('showall', this.elements);
    }
  };

  // --- Helpers ---

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

}(niviz, moment));