API Docs for: 0.0.1
Show:

File: lib/graphs/meteograph.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 Axis     = niviz.Axis;
  var Grid     = niviz.Grid;
  var round    = niviz.util.round;
  var header   = niviz.Header;
  var extend   = niviz.util.extend;

  /** @module niviz */

  /**
   * Visualization of a time series of parameters present in Meteo objects.
   * Multiple such parameters and groups thereof may be displayed simultanously.
   *
   * @class Meteograph
   * @constructor
   * @extends Rangegraph
   *
   * @param {Meteo} data Time series of data
   * @param {Object} canvas A svg element that will be used as SnapSVG paper
   * @param {Object} properties
   */
  function Meteograph (data, canvas, properties) {
    this.last_highlighted_index = 0; // See meteographer UI
    this.last_keyboard_highlighting_timeout = null; // See meteographer UI
    this.is_keyboard_highlighting = false; // See meteographer UI
    Meteograph.uber.call(this, data, canvas, properties);
    this.axisy = {};

    extend(this.properties, Meteograph.defaults.values);
    this.setProperties(extend(properties, { autoscale: false, use_settings: true }));

    this.parameters = [];
    if (data.fields.length > 1) this.parameters.push(data.fields[1]);

    this.draw();
  }

  Graph.implement(Meteograph, 'Meteograph', 'meteo', 'Rangegraph');

  Meteograph.defaults = new Config('Meteo', [
    niviz.Meteo.params,
    niviz.Meteo.group
  ], 'Time Series');

  Meteograph.defaults.load();

  defprops(Meteograph.prototype, {
    /**
     * Get all selectable parameters and parameter groups
     *
     * @property all
     * @type Array<String>
     */
    all: {
      get: function () {

        var arrays = this.data.fields.slice(1);
        this.properties.group.forEach(function (item) {
          arrays.push(item.name);
        }, this);

        return arrays;
      }
    }
  });

  function convert (c, value) {
    if (c && c.convert) return c.convert(value);
    return value;
  }

  /**
   * Setup all axes and configurations for all curves in this.curves. Every curve
   * may consist of one or more actual parameters, all configurations for every
   * parameter and an axis for every curve needs to be instantiated.
   *
   * @method setup
   * @private
   * @chainable
   */
  Meteograph.prototype.setup = function () {
    var p = this.properties;
    this.configurations = {};
    this.getcurves();

    // A few adjustments to use all available space
    p.tl.y = p.fontsize * 2;
    p.br.y = this.canvas.height() - p.fontsize * 2;
    p.length.y = p.br.y - p.tl.y;

    this.curves.forEach(function (curve) {
      var name = curve.name, min, max,
          c = this.configurations[name] = this.data.getconfig(name, p.params, p.group, this.properties.use_settings);

      curve.parameters.forEach(function (param) {
        var cc = this.data.getconfig(param, p.params, p.group, this.properties.use_settings);

        var datamin = convert(cc, this.data.min[param]),
          datamax = convert(cc, this.data.max[param]);

        if (min === undefined ) min = datamin;
        else if (min > datamin) min = datamin;

        if (max === undefined ) max = datamax;
        else if (max < datamax) max = datamax;
      }, this);

      var minmax = Grid.minmax(min, max, p.br.y - p.tl.y);

      if (!this.properties.autoscale) {
        if (c.min !== undefined) minmax.min = c.min;
        if (c.max !== undefined) minmax.max = c.max;
      }

      var r = Math.abs(minmax.min - minmax.max);
      var max = minmax.max -  r * this.yzoom.min / 100;
      var min = minmax.max - r * this.yzoom.max / 100;

      this.axisy[name] = new Axis(min, max, p.br.y, p.tl.y, c.log);

      curve.parameters.forEach(function (param) {
        this.configurations[param] = this.data.getconfig(param, p.params, p.group, this.properties.use_settings);
      }, this);
    }, this);

    return this.gridy().header();
  };

  /**
   * Parameters and parameter groups are mashed together in an object called 'curves'.
   * The 'curves' object associates names (e. g. ta or tsurf) with a parameters array.
   * @method getcurves
   * @private
   */
  Meteograph.prototype.getcurves = function () {
    var p = this.properties;
    this.curves = [];

    this.parameters.forEach(function (param) {
      if (this.data.fields.indexOf(param) > -1)
        this.curves.push({ name: param, parameters: [param] });
    }, this);

    p.group.forEach(function (obj) {
      this.curves.push(
        { name: obj.name,
          parameters: obj.group.trim().replace(/\s+/g, '').split(',').filter(function (el) {
            return this.data.fields.indexOf(el) > -1;
          }, this)
        });
    }, this);
  };

  /**
   * Prepare the ordinate axis (axes) that shall have a legend (up to two). Calculate
   * the grid points (call Grid.gridify). Estimate the pixel width of the axis annotation
   * and position the axis label accordingly. Call Meteograph:drawgrid on all legends
   * to be drawn.
   *
   * @method gridy
   * @chainable
   * @private
   */
  Meteograph.prototype.gridy = function () {
    var types = [], counter = 0, p = this.properties, legends = [], paper = this.paper;

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

    this.elements['gridy'] = paper.g();
    this.parameters.forEach(function (parameter) {
      var c = this.configurations[parameter], type = c.name,
          axis = this.axisy[parameter], grid;

      if (type && types.indexOf(type) === -1 && counter < 2) {
        grid = Grid.gridify(axis);

        var longest = (grid.heights.reduce(function (a, b) {
          var t1 = a + '', t2 = b + '';
          return t1.length > t2.length ? a : b;
        }) + '').length;

        if (!counter) {
          p.tl.x = p.fontsize + longest * p.fontsize;
        } else {
          p.br.x = this.canvas.width() - p.fontsize - longest * p.fontsize;
        }
        p.length.x = p.br.x - p.tl.x;

        legends.push({ c: c, h: grid.heights, a: axis, pos: counter ? 'right' : 'left' });
        counter++;
      }
    }, this);

    legends.forEach(function (l) {
      this.elements['gridy'].add(this.drawgrid(l.c, l.h, l.a, l.pos));
    }, this);

    p.cliprect = [p.tl.x, p.tl.y, p.length.x, p.length.y];

    return this;
  };

  /**
   * Draw the ordinate legend and grid for a specific parameter / parameter group.
   *
   * @method drawgrid
   * @private
   * @param {Object} config Configuration for a parameter / parameter group
   * @param {Array<Number>} heights Ordinate points to draw grid at
   * @param {Axis} axis The axis of the ordinate to calculate coords to pixels
   * @param {String} position 'left' if the legend should be placed on the left side
   * @return {Object} svg group container comprising legend and grid lines
   */
  Meteograph.prototype.drawgrid = function (config, heights, axis, position) {
    var p = this.properties, paper = this.paper, path = paper.g(), set = paper.g(), i, ii;

    for (i = 0, ii = heights.length; i < ii; ++i) {
      var height = heights[i], y = Math.round(axis.pixel(height)), lbly = y + p.fontsize / 2 - 2;

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

      set.add(paper.text(position === 'left' ? p.tl.x - 7 : p.br.x + 7, lbly, height + '')
              .attr(p.font).attr({
                textAnchor: position === 'left' ? 'end' : 'start'
              }));
    }

    var label = config.name + (config.unit ? ' [' + config.unit + ']' : '');
    set.add(paper.text((position === 'left' ? p.fontsize / 2 + 2 : this.canvas.width() - p.fontsize / 2 - 2),
                       p.tl.y + p.length.y / 2, label).attr(p.font)
            .transform('r270').attr({ fill: config.color || '#000' }));

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

    return set;
  };

  /**
   * Draw a header on the left top of the graph.
   * @method header
   * @private
   * @chainable
   */
  Meteograph.prototype.header = function () {
    var p = this.properties, paper = this.paper, meteo = this.data, text = '',
        position = meteo.position;

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

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

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

    var el = paper.text(p.tl.x, p.tl.y - p.fontsize, text).attr(p.font).attr({
      textAnchor: 'start'
    });

    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
   */

  Meteograph.prototype.mouseout = function () {
    if(this.is_keyboard_highlighting) {
      return;
    }
    this.unhighlight();
  };

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

  /**
   * Method to be called when mousemove event is detected. It calls the method
   * Meteograph:highlight in turn.
   *
   * @method mousemove
   * @private
   * @param {Object} e Mousemove DOM event object
   * @param {Object} canvas offset object
   */
  Meteograph.prototype.mousemove = function (currentdate, e) {
    if(this.is_keyboard_highlighting) {
      return;
    }
    var self = this, current;
    clearTimeout(this.timer);

    this.timer = setTimeout(function () {
      // 2020-10-07: Commenting out the following line, reason:
      // don't understand why the stardragdate should always
      // be recalculated when the mouse moves and there is already a startdragdate
      // self.startdragdate = moment.utc(currentdate);
      current = self.data.index(currentdate);
      self.highlight(current);
    }, 5);
  };

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

  /**
   * Method to be called at the start of dragging on top of cover.
   *
   * @method dragstart
   * @protected
   * @param {Moment} current Date at current drag position
   */
  Meteograph.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
   */
  Meteograph.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();
    }
  };

  /**
   * Display the values for the parameters in the graph at the current position of
   * the mouse cursor at the top left corner of the graph.
   *
   * @method legend
   * @private
   * @param {MeteoData} meteo The meteo object at the current cursor position
   */
  Meteograph.prototype.legend = function (meteo) {
    var p = this.properties, paper = this.paper, c, text;

    if (!this.legends) {
      var set = paper.g(), center = { x: p.tl.x + p.fontsize, y: p.tl.y + p.fontsize },
        half = p.fontsize / 2;

      this.legends = {};

      this.parameters.forEach(function (curvename) {
        this.curves.forEach(function (curve) {
          if (curve.name !== curvename) return;

          curve.parameters.forEach(function (parameter) {
            c = this.configurations[parameter];
            set.add(paper.circle(center.x, center.y, 6).attr({
              'fill': c.color || '#000', 'stroke-width': 0
            }));

            var txtlbl = paper.text(center.x + 6 + half, center.y + half - 3, parameter)
                .attr(p.font).attr({
                  'font-weight': 'bold', 'text-anchor': 'start', 'fill': c.color || '#000'
                });

            var txtval = paper.text(txtlbl.getBBox().x2 + half, center.y + half - 3,
                round(convert(c, meteo[parameter]), 2)).attr(p.font).attr({
                  'text-anchor': 'start', 'font': '12px Helvetica, Arial', 'fill': c.color || '#000'
                });

            center.x = txtlbl.getBBox().x2
              + 4 * p.fontsize + (c.unit ? c.unit.length * p.fontsize / 2 : 0);

            set.add(txtlbl);
            set.add(txtval);
            this.legends[curve.name + '_ ' + parameter] = txtval;
          }, this);
        }, this);
      }, this);

      this.elements['legends'] = set;
    } else {
      this.parameters.forEach(function (curvename) {
        this.curves.forEach(function (curve) {
          if (curve.name !== curvename) return;

          curve.parameters.forEach(function (parameter) {

            c = this.configurations[parameter];
            if (meteo[parameter] !== null) {
              text = round(convert(c, meteo[parameter]), 2);
              if (c.unit) text += ' ' + c.unit;
            } else {
              text = 'NULL';
            }

            this.legends[curve.name + '_ ' + parameter].attr({ text: text });
          }, this);
        }, this);
      }, this);
    }
  };

  /**
   * Highlight the current cursor position by adding dots at the x-coordinate of
   * the cursor and display the values for the parameters at that position in the
   * top left corner of the graph (actual drawing of the values is done by
   * Meteograph:legend).
   *
   * @method highlight
   * @private
   * @param {Number} current Index of current data in meteo data array
   */
  Meteograph.prototype.highlight = function (current) {
    if (current >= this.data.data.length || current < 0) {
      return;
    }
    this.last_highlighted_index = current;
    var paper = this.paper, set, meteo = this.data.data[current], p = this.properties;

    this.datelabel(meteo.timestamp);

    if (!this.dots) {
      this.dots = {};
      set = this.elements['dots'] = paper.g();
    }

    var x = this.axisx.pixel(parseInt(meteo.timestamp.format('X')));
    this.parameters.forEach(function (curvename) {
      this.curves.forEach(function (curve) {
        if (curve.name !== curvename) return;

        curve.parameters.forEach(function (parameter) {
          var name = curve.name + '_' + parameter, c = this.configurations[parameter],
              y = this.axisy[curve.name].pixel(convert(c, meteo[parameter]));

          if (!this.dots[name]) {
            var dot = paper.circle(x, y, 4).attr({
              fill: c.color  || '#000', strokeWidth: 0,
              clip: paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
            });

            dot.node.style['pointer-events'] = 'none';
            this.dots[name] = dot;
            set.add(dot);
          } else {
            if (meteo[parameter] !== null) this.dots[name].attr({ cx: x, cy: y });
          }
        }, this);
      }, this);
    }, this);

    this.legend(meteo);
  };

  /**
   * Remove all dots on the curves and the in-graph legend with the values
   * of the parameters at the current cursor position.
   * @method unhighlight
   * @private
   */
  Meteograph.prototype.unhighlight = function () {
    this.datelabel();
    if (this.dots) {
      this.remove('dots', this.elements);
      delete this.dots;
    }

    if (this.elements['legends']) {
      this.remove('legends', this.elements);
      delete this.legends;
    }
  };

  /**
   * 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
   */
  Meteograph.prototype.clip = function (start, end) {
    this.setup();

    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
   */
  Meteograph.prototype.reset = function () {
    this.yzoom = { min: 0, max: 100 };
    this.emit('dragstart', this.data.data[0].timestamp);
    this.emit('dragend', this.data.data[this.data.length - 1].timestamp);

    if (this.data && this.data.length)
      this.clip(this.data.data[0].timestamp, this.data.data[this.data.length - 1].timestamp);
    this.resetbutton(false);
  };

  /**
   * Given a start and end index (for the data property data in the Meteo object)
   * draw all parameters and parameter groups, removing any previously present curves.
   * 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
   */
  Meteograph.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.data[start].timestamp.isAfter(this.$range.start)) start--;
    if (this.data.data[end].timestamp.isBefore(this.$range.end))
      end = Math.min(end + 1, this.data.length - 1);

    start = Math.max(start, 0);
    end   = Math.max(end, 0);

    this.$indices = { start: start, end: end };

    this.parameters.forEach(function (parameter) {
      this.curves.forEach(function (curve) {
        if (curve.name !== parameter) return;

        this.remove(curve.name, this.elements);
        this.elements[curve.name] = this.paper.g();

        curve.parameters.forEach(function (p) {
          this.show(p, curve.name, this.elements[curve.name], start, end);
        }, this);
      }, this);
    }, this);
  };

  /**
   * Given an Axis object, draw a single curve for one parameter with
   * its ordinate values calculated on that axis.
   *
   * @method show
   * @private
   * @param {String} parameter Name of the parameter to be drawn (not a group)
   * @param {Axis} axis Axis object, the reference system for the ordinate values
   * @param {Object} svg group container object to append curve to
   * @param {Number} start Index of first data point to be displayed
   * @param {Number} end Index of last data point to be displayed
   */
  Meteograph.prototype.show = function (parameter, axis, set, start, end) {
    var paper = this.paper, p = this.properties, c = this.configurations[parameter],
      curve = [], curves = paper.g(), axisy = this.axisy[axis], i, pushm = true, path;

    for (i = start; i <= end; ++i) {
      if (i < 0) continue;
      var tmp = this.data.data[i], x = this.axisx.pixel(tmp.timestamp.format('X')), y;

      if (tmp[parameter] === null) { // nodata point
        pushm = true;
        continue;
      }

      y = axisy.pixel(convert(c, tmp[parameter]));

      if (pushm) {
        if (curve.length) { // draw last segment
          curves.add(paper.polyline(curve));
          curve = [];
        }

        curve.push(x, y); // start new segment
        pushm = false;
      } else {
        curve.push(x, y);
      }
    }

    if (curve.length) curves.add(paper.polyline(curve));

    path = curves.attr({
      strokeWidth: 1,
      strokeOpacity: 0.9,
      fill: 'none',
      stroke: c.color || '#000',
      pointerEvents: 'none',
      clip: paper.rect(p.tl.x, p.tl.y, p.length.x, p.length.y)
    });

    set.add(path);
  };

  /**
   * Set the set of parameters or group of parameters that shall be displayed.
   * The method removes all highlights and curves previously displayed and then
   * triggers a draw with the new set of parameters (and groups).
   *
   * @method set
   * @chainable
   * @param {Array<String>} parameters Array of parameter and group names
   */
  Meteograph.prototype.set = function (parameters) {
    this.unhighlight();
    this.setProperties(Meteograph.defaults.values);

    this.parameters.forEach(function (parameter) {
      this.remove(parameter, this.elements);
    }, this);

    this.remove('showall', this.elements);
    this.remove('cover');

    this.parameters = parameters;

    return this;
  };

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

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

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

    return this;
  };

  Meteograph.prototype.set_y_range = function (curve_name, min_value, max_value) {
    // this.properties.params.forEach(function (p) {
    //   if(p.name === curve_name) {
    //     p.min = min;
    //   }
    // });
    // NOTE: The above code would be the equivalent of changing
    //       the settings from the GUI, which is a more reliable way;
    //       but something overrides the changes later in the execution.

    var ax = this.axisy[curve_name];
    if (!ax) {
      throw new Error('Axis not found.');
    }
    var p = this.properties;
    var max = ax.pixel(min_value);
    var min = ax.pixel(max_value);
    if (max < min) {
      var tmp = max;
      max = min;
      min = tmp;
    }

    // Formulas from Rangegraph.prototype.draggable > mouseend (line 420)
    var r = Math.abs(this.yzoom.min - this.yzoom.max);
    var newmax = this.yzoom.min + ((max - p.tl.y) / p.length.y * r);
    var newmin = this.yzoom.min + ((min - p.tl.y) / p.length.y * r);

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

    // Render like in Rangegraph.prototype.draggable > mouseend (line 420)
    this.mouseon(true);
  };

  // --- Helpers ---

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

}(niviz, moment));