API Docs for: 0.0.1
Show:

File: lib/graphs/tabular.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/>.
 */

/*eslint space-infix-ops: [0, {"int32Hint": false}] */
/*eslint complexity: [2, 22]*/
(function (niviz, moment) {
  'use strict';

  // --- Module Dependencies ---
  var Graph     = niviz.Graph;
  var Common    = niviz.Common;
  var Config    = niviz.Config;
  var Feature   = niviz.Feature;
  var round     = niviz.util.round;
  var t         = niviz.Translate.gettext;
  var extend    = niviz.util.extend;
  var nivizNode = niviz.util.nivizNode;

  /** @module niviz */

  /**
   * Visualization of a singular snow profile in tabular form. This class is the parent
   * of the SLFProfile, StructureProfile and SimpleProfile classes.
   *
   * @class TabularProfile
   * @constructor
   *
   * @extends Graph
   */
  function TabularProfile (station, canvas, properties) {
    TabularProfile.uber.call(this, station, properties);

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

    this.canvas.empty();

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

    this.blanket = {};
    this.elements = {};
    this.columns = [];

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

    // make sure that the children retain the right to call draw themselves
    if (!(this instanceof niviz.SLFProfile) && !(this instanceof niviz.SimpleProfile)) {
      this.draw(station.profiles[0]);
    }
  }

  Graph.implement(TabularProfile, 'TabularProfile', 'profile');

  TabularProfile.defaults = new Config('Tabular Graph', [
    { name: 'fontsize',   type: 'number', default: 10 },
    { name: 'font_color', type: 'color', default: '#000000' },
    { name: 'grid_color', type: 'color', default: '#000000' }
  ]);

  TabularProfile.defaults.load();

  /**
   * A list of parameters that will be layed out directly, i. e.
   * at their real height and will not be placed among the stratigraphic
   * parameters.
   *
   * @property nopos
   * @type Array<String>
   * @static
   */
  TabularProfile.nopos = ['ct', 'ect', 'rb', 'sf', 'saw', 'threads', 'density'];

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

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

    p.height = this.canvas.height(); //get the container height into variable h
    p.width  = this.canvas.width();

    if (!this.paper || p.height !== oldheight || p.width !== oldwidth) {
      if (this.paper) this.paper.remove();
      this.paper = Snap(this.canvasnode);
    }

    p.table = {};
    p.table.max    = Math.max(Math.floor((station.top + 40) / 40) * 40, 200);
    p.table.min    = 0;
    p.table.range  = p.table.max - p.table.min;
    p.table.top    = 4 * p.fontsize;
    p.table.bottom = 1;
    p.table.height = p.height - p.table.top - p.table.bottom;
    p.table.dock   = 0; // where the interconnector starts

    p.table.origin = {
      x: 150,
      y: p.table.top + p.table.height
    };

    p.table.labels = {
      symbol: p.table.top - 2 * p.fontsize - 5,
      unit: p.table.top - p.fontsize / 2 - 5
    };

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

  /**
   * A closure that provides the property this.table().width
   * to get the exact width of the table.
   *
   * @method table
   * @protected
   */
  TabularProfile.prototype.table = function () {
    var self = this, p = this.properties;

    var width = function () {
      var sum = self.columns.reduce(function(a, b) {
        if (self.profile[b.parameter]) return a + b.width;
        return a;
      }, 0);

      return Math.ceil(sum * p.fontsize);
    };

    return {
      width: width()
    };
  };

  /**
   * Set all possible columns of the table. These consist of the fixed
   * columns for stratigraphic parameters and some configured by the user.
   *
   * @method loadcolumns
   * @private
   */
  TabularProfile.prototype.loadcolumns = function () {
    var p = this.properties;

    this.columns.length = 0; // clear

    // The following columns are non-negotiable
    this.add({width: 3, parameter: 'wetness'});
    this.add({width: 5, parameter: 'grainshape'});
    this.add({width: 6, parameter: 'grainsize'});
    this.add({width: 3, parameter: 'hardness'});

    p.other_parameters && p.other_parameters.forEach(function (e) {
      if (e.name === 'thickness') this.add({ width: e.width, parameter: e.name });
    }, this);

    //this.add({width: 3, parameter: 'thickness'});
    //this.add({width: 5, parameter: 'ramm'});
    //this.add({width: 4, parameter: 'temperature'});
    p.stability_parameters && p.stability_parameters.forEach(function (e) {
      if (e.name === 'flags') {
        this.add({ width: Common.defaults.display_yellow_flags === 'as flags' ? 7 : 4,
                   parameter: e.name });
      }
    }, this);

    p.other_parameters && p.other_parameters.forEach(function (e) {
      if (e.name !== 'thickness' && e.name !== 'comments')
        this.add({ width: e.width, parameter: e.name });

      if (e.name === 'comments') {
        this.properties.show_layer_comments = true;
      }
    }, this);

    p.stability_parameters && p.stability_parameters.forEach(function (e) {
      if (e.name !== 'flags') this.add({ width: e.width, parameter: e.name });
    }, this);

    // this.add({width: 7, parameter: 'flags'});
    // this.add({width: 4, parameter: 'ct'});
    // this.add({width: 5, parameter: 'threads'});
  };

  /**
   * Add a column to the table for a certain parameter.
   *
   * @method add
   * @private
   * @param {Object} object An object with a width and parameter property
   * @param {Number} [position] Where to insert the column. Default: at the end.
   */
  TabularProfile.prototype.add = function (object, position) {
    if (position === undefined) position = this.columns.length;

    if (position > -1 && position <= this.columns.length)
      this.columns.splice(position, 0, object);
  };

  /**
   * Remove a column from the table.
   *
   * @method remove
   * @private
   * @param {Number} column The column index to remove
   */
  TabularProfile.prototype.remove = function (column) {
    if (column > -1) this.columns.splice(column, 1);
  };

  /**
   * Draw the TabularProfile and set up the mouse events.
   *
   * @method draw
   * @param {Profile} profile
   */
  TabularProfile.prototype.draw = function (profile) {
    if (profile) this.profile = profile;
    if (!this.profile) return;

    this.loadcolumns();
    this.config(); // Recalculate certain parameters

    this.partitions = [];
    this.arrange(this.partitions);

    this.draw_columns(this.partitions);
  };

  /**
   * Zoom in on a certain snow height range.
   *
   * @method zoom
   * @protected
   * @param {Object} range An object with a min, max and axis property
   */
  TabularProfile.prototype.zoom = function (range) {
    this.range = range;
    this.config(); // Necessary to recalculate table properties

    this.blanket = {};
    this.draw_columns(this.layout(this.select()));
  };

  /**
   * Once the zoomed primary partition has been subjected to layout
   * adjust all other partitions to represent the initial relations.
   *
   * @method align
   * @private
   * @param {Array< Array<Object> >} partitions
   * @return {Array< Array<Object> >} Partitions in zoomed range.
   */
  TabularProfile.prototype.align = function (partitions) {
    var i, j, k, primary = partitions[partitions.primeindex];

    for (k = 1; k < primary.length; ++k) {
      var members = primary[k].members,
        rheight = Math.abs(primary[k].y - primary[k-1].y);

      for (i = 0; i < members.length; ++i) {
        var index = members[i].index;
        var elements = members[i].elements;
        var nopos = partitions[index].nopos;

        for (j = 0; j < elements.length; ++j) {
          if (nopos) {
            partitions[index][elements[j]].y = this.y(partitions[index][elements[j]].height);
            continue;
          }

          var ratio = Math.abs(partitions[index][elements[j]].y -
            (primary[k].original || primary[k].y)) / primary[k].rheight;

          if (primary[k].rheight === 0) {
            partitions[index][elements[j]].y = primary[k].y;
            continue;
          }

          partitions[index][elements[j]].y = primary[k].y + rheight * ratio;
        }
      }
    }

    return partitions;
  };

  /**
   * Layout the primary partition in zoomed state.
   *
   * @method layout
   * @private
   * @param {Array< Array<Object> >} partitions
   * @return {Array< Array<Object> >} Partitions in zoomed range.
   */
  TabularProfile.prototype.layout = function (partitions) {
    var primeindex = partitions.primeindex, i,
      primary = partitions[primeindex], p = this.properties.table,
      ypx, diff, myspace;

    // Greedy strategy to the top, if possible
    var space = p.height - partitions.height;
    for (i = primary.length - 1; i >= 0; --i) {
      //if (primary[i].layer === undefined) continue; // Ignore the fake top layer
      ypx = this.y(primary[i].height);

      diff = primary[i].y - ypx;
      myspace = 0;

      if (diff > 0) {
        myspace = Math.min(space, diff);

        if (myspace > 0) {
          primary[i].original = primary[i].y;
          primary[i].y -= myspace;
          primary[i].shift = myspace;
        }
      }
      space = myspace;
      if (space === 0) break;
    }

    // Greedy strategy moving towards bottom
    space = primary.length > 1 ? primary[1].y + primary[1].rheight : 0;
    space = this.y(p.min) - space;

    if (space < 0) space = 0;
    for (i = 1; i < primary.length; ++i) {
      ypx = this.y(primary[i].height);

      diff = primary[i].y - ypx;
      myspace = 0;

      if (diff < 0) {
        myspace = Math.min(space, Math.abs(diff));

        if (myspace > 0) {
          primary[i].original = primary[i].y;
          primary[i].y += myspace;
          primary[i].shift = (primary[i].shift ? primary[i].shift : 0) - myspace;
        }
      }
      space = myspace;
      if (space === 0) break;
    }
    primary[0].y = Math.min(this.y(primary[0].height), this.y(p.min));
    if (primary.length > 1)
      primary[0].y = Math.max(primary[1].y + primary[1].rheight, primary[0].y);

    return this.align(partitions);
  };

  /**
   * Select all layers in all partitions that are within p.table.min and p.table.max.
   *
   * @method select
   * @private
   * @return {Array< Array<Object> >} Partitions in zoomed range.
   */
  TabularProfile.prototype.select = function () {
    var ii, partitions = extend([], this.partitions),
        primeindex = partitions.primeindex, i, kk, jj,
        primary = partitions[primeindex], current = [],
        p = this.properties.table, sumheight = 0;

    for (i = 0; i < this.columns.length; ++i) {
      current[i] = [{ height: this.partitions[i][0].height,
                      y: this.y(this.partitions[i][0].height), layer: 0 }];
    }

    for (ii = 1; ii < primary.length; ++ii) {
      var lastheight = ii ? primary[ii-1].height : 0,
        c = primary[ii].height;

      if (lastheight < p.max && c > p.max) {
        c = p.max;
      } else if (primary[ii].height > p.max || primary[ii].height < p.min) continue;

      current[primeindex].push({
        height: primary[ii].height,
        y: primary[ii].y,
        layer: primary[ii].layer
      });

      var cc = current[primeindex].length - 1;
      var h1 = primary[ii].height, h2 = Math.max(primary[ii-1].height, p.min);

      // Push all other columns
      var columns = [];
      for (jj = primeindex + 1; jj < partitions.length; ++jj) {
        var partition = partitions[jj];
        if (partitions[jj].nopos) current[jj].nopos = true;
        var rows = [];
        for (kk = 1; kk < partition.length; ++kk) {
          var height = partition[kk].height;

          if (current[jj].nopos && (height < p.min || height > p.max)) continue;

          if ((h1 === h2 && height === h1) || (height <= h1 && height > h2)) {
            if ((height === h1) || height <= c) {
              current[jj].push({
                height: height,
                y: partition[kk].y,
                layer: partition[kk].layer
              });

              var x = current[jj][current[jj].length - 1];
              if (x.height === h1) x.height = c;

              rows.push(current[jj].length - 1);
            }
          }
        }
        columns.push({ index: jj, elements: rows });
      }

      current[primeindex][cc].members = columns;
      current[primeindex][cc].rheight =
        Math.abs(primary[ii].y - primary[ii-1].y);

      sumheight += current[primeindex][cc].rheight;

      if (lastheight < p.max && c >= p.max) {
        current[primeindex][cc].height = c;
      }
    }

    current.rows = primary.length;
    current.height = sumheight;
    current.primeindex = primeindex;

    return current;
  };

  /**
   * Initially (without zooming) arrange all the layers in the table by
   * first allocating enough space for the primary (first) column to be
   * rendered and then increasing the table size in case more space is needed
   * for later columns.
   *
   * @method arrange
   * @private
   * @param {Array< Array<Object> >} partitions
   */
  TabularProfile.prototype.arrange = function (partitions) {
    var p = this.properties, height = p.fontsize * 1.5, i,
      primeindex = 0, primary = false;

    this.initialheight = p.table.height;
    for (i = 0; i < this.columns.length; ++i) {
      var column = this.columns[i], parameter = column.parameter,
        data = this.profile[parameter];

      partitions[i] = [{ height: p.table.min, y: this.y(p.table.min) }];
      if (!data
          || (data.elements && !data.elements[0])
          || !data.layers.length) continue;

      partitions[i] = [{ height: data.bottom, y: this.y(data.bottom) }];
      var resizesize = 0;
      if (!primary) {
        //console.log('Primary partition: ' + parameter);
        resizesize = this.partition(partitions[i], data, height);
        //console.dir(partitions[i]);
        primeindex = i;
        primary = true;
      } else if (TabularProfile.nopos.indexOf(data.type) > -1) {
        this.directlayout(partitions[i], data);
        partitions[i].nopos = true;
      } else {
        // all stratigraphic layers need to have the same tops and bottoms
        partitions[i] = JSON.parse(JSON.stringify(partitions[primeindex]));

        // Legacy code: keep in case non-stratigraphic layers need to be layed out
        // partitions[i] = [];
        // resizesize = this.position(partitions, i, primeindex, data, height);
        // this.canvas.height(p.height + resizesize);
        // this.config();
      }

      if (resizesize > 0 && i === primeindex) {
        this.canvas.height(resizesize + p.table.bottom + p.table.top);
        this.config();
        partitions[i] = [{ height: p.table.min, y: this.y(p.table.min) }];
        resizesize = this.partition(partitions[i], data, height);

        if (resizesize) throw new Error('resizesize should be 0');
      }
    }
    partitions.primeindex = primeindex;
  };

  /**
   * For parameters that are not layed out relatively to the primary partition:
   * directly place them at the actual height of the layer
   *
   * @method directlayout
   * @private
   * @param {Array<Object>} partition The partition (column) to be layed out
   * @param {Feature} data
   */
  TabularProfile.prototype.directlayout = function (partition, data) {
    var i, ii = data.layers.length, ypx, p = this.properties.table, heights = [];

    for (i = 0; i < ii; ++i) {
      var current = data.layers[i].top;
      if (current === undefined && (data.type === 'rb'
                                    || data.type === 'saw'
                                    || data.type === 'sf'
                                    || data.type === 'ect'
                                    || data.type === 'ct'))
        current = this.profile.top;

      if (current > p.max || current < p.min || heights.indexOf(current) !== -1) continue;
      heights.push(current);
      ypx = this.y(current);

      partition.push({ y: ypx, height: current, layer: i });
    }
  };

  /**
   * Primary (first existant) column layout. If there is too little space to
   * fit the column layers than a value >0 is returned. The canvas needs to
   * be resized appropriately.
   *
   * @method partition
   * @private
   * @param {Array<Object>} partition The primary (column) to be layed out
   * @param {Feature} data
   * @param {Number} height The minimal height of a table row
   * @return {Number} Number of pixels to enlarge the canvas
   */
  TabularProfile.prototype.partition = function (partition, data, height) {
    var ii, jj, reserve = 0, total = (data.layers.length + 2) * height,
        p = this.properties.table, h;

    reserve = this.y(p.min) - partition[0].y;

    if (total > p.height) return total; // Need to enlarge canvas

    for (ii = 0; ii < data.layers.length; ++ii) {
      var space, current = data.layers[ii].top, ypx = this.y(current);

      //console.dir('current: ' + current + ' ypx: ' + ypx);
      if (current > p.max || current < p.min) continue;

      // Calculate how much space would be left for the current layer:
      // If this turns out to be negative then we need to rearrange the table
      space = partition[partition.length-1].y - ypx - height;

      partition.push({
        y: ypx,
        height: current,
        layer: ii,
        original: data.layers[ii].top
      });

      var now = partition.length - 1, old = now - 1;
      if (space >= 0) { // enough space to place new layer at real position
        reserve += space;
      } else { // problem - not enough space, shifting down might help
        var total_shift = Math.abs(space);

        if (reserve >= total_shift) { // shift down possible
          reserve -= total_shift;

          for (jj = old; jj >= 0; --jj) {
            var necessary_shift = Math.abs(partition[jj].y - partition[jj+1].y) - height;
            if (necessary_shift >= 0) { // no shift necessary, we're done
              break;
            } else {
              partition[jj].y -= necessary_shift;
            }
          }
        } else { // shift up necessary
          if (reserve > 0) { // Get rid of any extra space down below
            for (jj = 0; jj < old; ++jj) {
              partition[jj+1].y = partition[jj].y - height;
            }
            reserve = 0;
          }

          partition[now].y = partition[old].y - height;
        }
      }
    }

    // What if the layer does not reach the profile height?
    // We'll add fake layer at the top of the profile, so the primary
    // partition still remains the reference for other partitions
    if (data.layers[ii-1].top < this.profile.top) {
      h = this.y(this.profile.top);
      if (partition.length && partition[partition.length - 1].y < h) {
        var tmp = this.y(partition[partition.length - 1].height) - h;
        h = partition[partition.length - 1].y - Math.min(height, tmp);
      }

      partition[partition.length] = { // Add a fake layer at the top
        y: h,
        height: this.profile.top,
        layer: undefined // Check this to know whether it's fake
      };
    }

    // What if the layer does not reach the profile bottom?
    // We'll add fake layer at the bottom of the profile, so the primary
    // partition still remains the reference for other partitions
    if (partition[0].height > this.profile.bottom) {
      h = this.y(this.profile.bottom);

      partition.splice(0, 0, { // Add a fake layer at the bottom
        y: h,
        height: this.profile.bottom,
        layer: undefined // Check this to know whether it's fake
      });
    }

    return 0;
  };

  /**
   * Position another column relatively to the primary partition.
   *
   * @method position
   * @private
   * @param {Array< Array<Object> >} partitions All partitions
   * @param {Number} index Index of partition to be positioned
   * @param {Number} primeindex Index of primary partition
   * @param {Feature} data
   * @param {Number} height The minimal height of a table row
   * @return {Number} Number of pixels to enlarge the canvas
   */
  TabularProfile.prototype.position = function (partitions, index, primeindex, data, height) {
    var ii, jj, sum = 0, p = this.properties.table, heights = [],
      partition = partitions[index], primary = partitions[primeindex];

    for (ii = -1; ii < data.layers.length; ++ii) {
      var current = ii >= 0 ? data.layers[ii].top : data.layers[0].bottom;

      if (current > p.max || current < p.min || heights.indexOf(current) !== -1) continue;
      heights.push(current);

      for (jj=1; jj < primary.length; ++jj) {
        if (primary[jj].height >= current) {
          //console.dir(ii + ' into bin ' + jj + ', ' + current + ' <= ' + primary[jj].height);
          break;
        }
      }

      if (jj >= primary.length) continue; // Out of the scope of the table // HACK assert

      // calculate position within bin
      var relative = primary[jj].height - primary[jj-1].height, absolute;
      if (relative !== 0) {
        relative = (current - primary[jj-1].height) / relative;
        absolute = primary[jj-1].y - (relative) * (primary[jj-1].y - primary[jj].y);
      } else {
        absolute = primary[jj].y;
      }
      //console.dir('Position: ' + absolute + '  - below bin '
      //  + jj + ' - ' + primary[jj].y + 'px');

      var diff = ii>= 0? Math.abs(partition[partition.length - 1].y - absolute) : height;
      partition.push({ y: absolute, height: current, layer: ii >=0 ? ii : undefined });
      if (diff < height) {
        var shift = (height - diff);
        this.shiftdown(partitions, primeindex, jj, index, shift, height);
        sum += shift;
      }
    }

    return sum;
  };

  /**
   * Shift down all partitions already layed out up until a certain
   * partition and layer height.
   *
   * @method shiftdown
   * @private
   * @param {Array< Array<Object> >} partitions All partitions
   * @param {Number} primeindex Index of primary partition
   * @param {Number} primepos Only shift down below this height of the primary partition
   * @param {Number} index Index up to which shifting may occur (i. e. currently positioned)
   * @param {Number} shift Number of pixels to shift down
   * @param {Number} height Minimal height of a row
   */
  TabularProfile.prototype.shiftdown = function (partitions, primeindex,
                                                 primepos, index, shift, height) {
    var primary = partitions[primeindex], partition = partitions[index], kk, ll;

    // Use greedy algorithm to shift everything down:
    // Table canvas will be resized appropriately
    for (kk = primeindex; kk <= index; ++kk) {
      var p = partitions[kk];
      for (ll = 0; ll < p.length; ++ll) {
        if (p[ll].height >= primary[primepos].height) break;
        if (kk === index && ll === (partition.length - 1)) break;

        p[ll].y += shift;
      }
    }
  };

  /**
   * Connect table and graph through dotted lines and store reference in an object
   * called this.blanket to later reference the individual lines.
   *
   * @method connect
   * @private
   * @param {Array<Object>} partition Primary partition, to be connected to graph
   * @param {Number} offset Pixel position of the right edge of the graph
   */
  TabularProfile.prototype.connect = function (partition, offset) {
    var paper = this.paper, p = this.properties, ii, cnr = 0, profile = this.profile,
      blanket = this.blanket, set = paper.g(), comments = profile.comments, cmax = 0;

    if (p.show_layer_comments) {
      cmax = this.profile.comments.layers.filter(function (l) { return l.value}).length;
    }

    blanket.length = 0;
    for (ii = 1; ii < partition.length; ++ii) {
      if (partition[ii].layer === undefined) continue; // Fake top or bottom layer
      var ypx = Math.round(this.y(partition[ii].height));

      var path = paper.g().add(paper.line(offset, Math.round(partition[ii].y), p.table.dock, ypx));
      if (p.show_layer_lines) path.add(paper.line(p.table.dock, ypx, p.origin.x, ypx));

      if (p.show_layer_comments && comments && comments.layers[ii - 1] && comments.layers[ii - 1].value) {
        set.add(paper.text(offset - 1, Math.round(partition[ii].y) + p.fontsize, '(' + (cmax - cnr) + ')')
          .attr(p.font)
          .attr({
            fontSize: p.fontsize - 2,
            textAnchor: 'end'
          }));

        ++cnr;
      }

      path.attr({
        strokeDasharray: '1,1',
        stroke: p.grid_color,
        strokeWidth: 0.5
      }).transform('t0.5,0.5');

      blanket[partition[ii].height] = {
        connector: path,
        elements: [], // an array of horizontal table lines
        area: { top: partition[ii].y, bottom: partition[ii-1].y }
      };

      set.add(path);
    }

    return set;
  };

  /**
   * Draw a column header at the position specified by offset.
   *
   * @method header
   * @private
   * @param {Number} offset Pixel position
   * @param {Object} options Holds the symbol and unit properties
   * @param {Feature} data
   */
  TabularProfile.prototype.header = function (offset, options, data) {
    var paper = this.paper, set = paper.g(), p = this.properties.table,
      center = offset + 4, flags = (Common.defaults.display_yellow_flags === 'as flags');

    if (data.type === 'flags' && flags) { // requires a special header
      set.add(this.flags(offset, data));
    } else {
      set.add(paper.text(center, p.labels.symbol, t(options.symbol || data.symbol || ''))
              .attr(p.font).attr('text-anchor', 'start'));
      if (data.type !== 'wetness' && data.type !== 'hardness')
        set.add(paper.text(center, p.labels.unit, options.unit || '')
                .attr(p.font).attr('text-anchor', 'start'));
    }

    return set;
  };

  /**
   * Draw a column header for the yellow flags feature.
   *
   * @method flags
   * @private
   * @param {Number} offset Pixel position
   * @param {Feature} data
   */
  TabularProfile.prototype.flags = function (offset, data) {
    var paper = this.paper, set = paper.g(), ii,
        p = this.properties, center = p.table.labels.unit + p.fontsize,
        labels = ['E', 'R', 'F', '\u0394E', '\u0394R', 'Depth'];

    for (ii = 0; ii < labels.length; ++ii) {
      set.add(paper.text(offset + (ii + 1) * p.fontsize, center, labels[ii])
              .attr(p.table.font).transform('r270').attr('text-anchor', 'start'));
    }

    return set;
  };

  /**
   * Draw all columns.
   *
   * @method draw_columns
   * @private
   * @param {Array< Array<Object> >} partitions All partitions
   */
  TabularProfile.prototype.draw_columns = function (partitions) {
    var jj, p = this.properties, offset =  p.table.origin.x, paper = this.paper,
      lineattr = {'stroke': p.grid_color, 'stroke-width': 0.5},
      set = paper.g(), bwidth;

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

    for (jj = 0; jj < this.columns.length; ++jj) {
      var column = this.columns[jj], width = column.width,
        parameter = column.parameter, data = this.profile[parameter];

      var layer_path = [], partition = partitions[jj], ii = partition.length, i, nopos;

      if (!data
          || (data.elements && (!data.elements[0] || !data.layers.length))
          || (!data.layers.length)) continue;

      if (jj === partitions.primeindex)
        set.add(this.connect(partition, offset));

      var options = Feature.type[data.type] || undefined;
      set.add(this.header(offset, options || {}, data));

      // Vertical separators
      layer_path = paper.line(offset, p.table.labels.symbol - p.fontsize,
                              offset, p.table.height + p.table.top);

      if (!nopos && TabularProfile.nopos.indexOf(data.type) > -1) {
        nopos = true;
        set.add(layer_path.attr({stroke: p.grid_color, strokeWidth: 2}));
        bwidth = offset - p.table.origin.x;
      } else {
        set.add(layer_path.attr(lineattr).transform('t0.5,0.5'));
      }

      for (i = 1; i < ii; ++i) {
        var layer = partition[i].layer, row = paper.g();

        if (layer === undefined) continue; // Fake top or bottom layer

        var y = Math.round(partition[i].y), depth = partition[i].height,
          lasty = Math.round(partition[i-1].y), height;

        if (this.range) lasty = Math.min(lasty, Math.round(this.y(this.range.min)));

        height = lasty - y;

        if ((data.type !== 'comments' || data.layers[layer].value) && y >= p.table.top)
          row.add(paper.line(offset, y, offset + width * p.fontsize, y));

        if (data.type === 'density') {
          // draw the bottom of the density layer in certain cases
          var ty = Math.round(this.y(data.layers[layer].bottom));
          height = ty - y;

          if ((ty <= p.table.origin.y && ty >= p.table.top) && (i === 1 || lasty !== ty))
            row.add(paper.line(offset, ty, offset + width * p.fontsize, ty));
        }

        row.attr(lineattr).transform('t0.5,0.5');
        set.add(row);

        //visualize the values
        set.add(this.toString(data.layers[layer], data.type, offset, y, width, height, layer));

        if (depth in this.blanket) { //add elements to this row
          this.blanket[depth].elements.push(row);
        }
      }
      offset += width * p.fontsize;
    }
    p.table.width = offset - p.table.origin.x;
    p.table.bwidth = bwidth || p.table.width;

    // Draw the outer frame of the table
    layer_path = paper.g();
    layer_path.add(paper.line(p.table.origin.x, p.table.origin.y, offset, p.table.origin.y));
    layer_path.add(paper.line(p.table.origin.x, p.table.top, offset, p.table.top));
    layer_path.add(paper.line(offset, p.table.labels.symbol - p.fontsize / 2,
                              offset, p.table.origin.y));

    // Draw bottom line of profile
    var cp = partitions[partitions.primeindex], l = 0;
    if (cp.length > 1 && cp[1].layer === undefined) l = 1;

    if (isNaN(cp[l].y) || !isFinite(cp[l].y)) return;

    layer_path.add(paper.line(p.table.origin.x,
                   Math.round(cp[l].y), p.table.origin.x + (bwidth || 0), Math.round(cp[l].y)));
    set.add(layer_path.attr(lineattr).transform('t0.5,0.5'));

    if (p.table.width > p.fontsize * 10)
      this.slope(p.table.origin.x, p.table.origin.y + p.fontsize, set);

    if (this.mat) this.mat.remove();
    this.mat = paper
      .rect(p.table.origin.x, p.table.top, offset - p.table.origin.x, p.table.height)
      .attr({ 'stroke-width': 0, 'fill-opacity': 0.0, 'fill': '#F0F' });
    this.mouseon();

    set.add(this.mat);
    this.elements['table'] = set;
  };

  /**
   * Draw the slope angle at the bottom of the table and display exposition.
   *
   * @method slope
   * @private
   * @param {Number} x x-coordinate
   * @param {Number} y y-coordinate
   * @param {Object} svg group container to append to
   */
  TabularProfile.prototype.slope = function (x, y, set) {
    var p = this.properties, paper = this.paper, path = paper.g(),
      position = this.station.position, length = 42,
      lineattr = {'stroke': p.grid_color, 'stroke-width': 2},
        font = { fontSize: '18px', fontFamily: 'Helvetica, Arial' };

    if (position && position.angle !== undefined && position.angle !== '') {
      if (position.angle === 0) {
        path.add(paper.line(x + length, y, x, y));
      } else if (position.angle > 0) {
        length = position.angle < 55 ? 42 : 30;
        var rad = position.angle * Math.PI / 180, pos = {
          x: x + Math.abs(Math.cos(rad) * length),
          y: y + Math.abs(Math.sin(rad) * length)
        };

        path.add(paper.line(x, pos.y, pos.x, pos.y));
        path.add(paper.line(pos.x, pos.y, x, y));
      }

      set.add(path.attr(lineattr));

      var text = '';
      if (this.station.position.direction !== '')
        text += t(this.station.position.direction, 'directions');

      if (position.angle !== '')
        text += (text ? ' / ' : '') + position.angle + '°';

      set.add(paper.text(x + length + 18 * .5, y + 16, text).attr(font));
    }
  };

  /**
   * Draw the text of the current layer and column, depending on the type of
   * paramater different renderings are required.
   *
   * @method toString
   * @private
   * @param {Value} value The current value
   * @param {String} type The parameter name
   * @param {Number} offset Pixel offset of the current table column
   * @param {Number} y Pixel y-coordinate of the layer top
   * @param {Number} width Column width
   * @param {Number} height Row height
   * @param {Number} layer Layer index (for cross-reference)
   * @return {Object} svg group container
   */
  TabularProfile.prototype.toString = function (value, type, offset, y, width, height, layer) {
    var content, string = '', p = this.properties, paper = this.paper;

    var txt = p.table.font,
      center = {
        x: offset + width / 2 * p.fontsize,
        y: y + p.fontsize - 1,
        c: y + height / 2 + p.fontsize / 2 - 1,
        above: y - 2
      };

    if (center.c > p.table.origin.y) center.c = p.table.origin.y - p.fontsize / 2;
    if (center.c < (p.table.top + p.fontsize)) center.c = p.table.top + p.fontsize;

    if (type === 'grainshape') {
      content = paper.text(center.x, center.c, value.code).attr(
        { fontSize: (p.fontsize + 3) + 'px', fill: p.font_color,
          textAnchor: 'middle', fontFamily: 'snowsymbolsiacs' });
    } else if (type === 'grainsize') {
      if (value.avg && value.avg !== value.max) {
        string += round(value.avg, 2);
        if (value.max) string += ' - ';
      }
      if (value.max) string += round(value.max, 2);
      content = paper.text(center.x, center.c, string).attr(txt);
    } else if (type === 'density') {
      if (value.value || value.value === 0)
        string += Math.round(value.value);
      content = paper.text(center.x, center.c, string).attr(txt);
    } else if (type === 'ct') {
      content = this.cttxt(paper, p, center, txt, value);
    } else if (type === 'saw' || type === 'sf') {
      content = this.stbtxt(paper, p, center, txt, value);
    } else if (type === 'ect') {
      content = this.ecttxt(paper, p, center, txt, value);
    } else if (type === 'rb') {
      content = this.rbtxt(paper, p, center, txt, value);
    } else if (type === 'flags') {
      content = this.flagstxt(paper, p, center, txt, value, offset);
    } else if (type === 'wetness') {
      var val = (value.index === 1) ? '' : value.index;

      if (Common.defaults.LWC_format !== '1 - 5') {
          val = value.code;
      }

      content = paper.text(center.x, center.c, val).attr(txt);
    } else if (type === 'threads') {
      content = paper.text(center.x, center.y, t(value.value)).attr(txt);
    } else if (type === 'comments') {
      content = this.commentstxt(paper, p, center, txt, value, offset, width, height, layer);
    } else {
      if (value.value && /[a-z]/i.test(value.value)) {
        string += t(value.value);
      } else if (value.value || value.value === 0) {
        string += round(value.value, 2);
      }
      content = paper.text(center.x, center.c, string).attr(txt);
    }

    return content;
  };

  /**
   * Render the cell text for the comments type.
   *
   * @method commentstxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The layer object,
   * @param {Value} offsetx The x-coord at the left of the column
   * @param {Value} width Max-width number of fontsizes
   * @param {Value} height Height of the cell in pixel
   * @param {Number} lindex Layer index (for cross referencing)
   * @return {Object} svg group container
   */
  TabularProfile.prototype.commentstxt = function (paper, p, center, txt, layer,
                                                   offsetx, width, height, lindex) {

    var critical = this.profile.critical && this.profile.critical.layers.length
      && this.profile.critical.layers[lindex]
      && this.profile.critical.layers[lindex].text || '';

    if (critical) critical = 'Critical layer (' + critical + '); ';

    var content = paper.g(), string = critical + (layer.value || ''),
      textbox = paper.text(0, -100, ''),
      words = string.split(/[ ]/) || [], i, vh = 1, last = '', result = '',
      lines = Math.max(Math.floor(height / (p.fontsize * 1.2)), 5);

    width = width * p.fontsize - 10;

    for (i = 0; i < words.length; ++i) {
      last = last + words[i] + ' ';
      textbox.attr('text', last);

      if (textbox.getBBox().width > width) {
        ++vh;

        if (vh >= lines) {
          result += '…';
          --vh;
          break;
        }

        result += '\n' + words[i] + ' ';
        last = '';
      } else {
        result += words[i] + ' ';
      }
    }

    textbox.remove(); // only used for layouting

    content.add(paper.multitext(offsetx + 5, center.y, result)
                .attr(txt)
                .attr({ 'text-anchor': 'start' }));
    return content;
  };

  /**
   * Render the cell text for the flags parameter. Either asterisks or flags.
   *
   * @method flagstxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The flags value object
   * @param {Number} offset The column offset
   * @return {Object} svg group container
   */
  TabularProfile.prototype.flagstxt = function (paper, p, center, txt, layer, offset) {
    var content = paper.g(), string = '', ii,
      labels = ['gs', 'h', 'gt', 'dgs', 'dh', 'depth'],
      display_flags = (Common.defaults.display_yellow_flags === 'as flags');

    for (ii = 0; ii < labels.length; ++ii) {
      if (!layer || !layer.value[labels[ii]]) continue;
      if (display_flags) {
        content.add(
          paper.text(p.fontsize * ii + offset + p.fontsize + 1, center.y + p.fontsize * 0.75, '*')
            .attr(txt)
            .attr({'font-size': p.fontsize * 1.5,
                   'fill': layer.value.sum > 4 ? '#DD0000' : '#000'})
        );
      } else {
        string += '*';
      }
    }

    if (!display_flags && layer) {
      content.add(
        paper.text(center.x, center.y + p.fontsize * 0.75, string).attr(txt)
          .attr({'fill': layer.value.sum > 4 ? '#DD0000' : '#000',
                 'font-size': p.fontsize * 1.5})
      );
    }

    return content;
  };

  /**
   * Render the cell text for the CT parameter.
   *
   * @method cttxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The flags value object
   * @return {Object} svg group container
   */
  TabularProfile.prototype.cttxt = function (paper, p, center, txt, layer) {
    var content = paper.g(), string = layer.text;
    content.add(paper.multitext(center.x, center.y, string).attr(txt));
    return content;
  };

  /**
   * Generic rendering for stability types.
   *
   * @method stbtxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The flags value object
   * @return {Object} svg group container
   */
  TabularProfile.prototype.stbtxt = function (paper, p, center, txt, layer) {
    var content = paper.g(), string = layer.text;

    if (layer.character) string += '\n' + layer.character;

    content.add(paper.multitext(center.x, center.y, string).attr(txt));
    return content;
  };

  /**
   * Render the cell text for the ECT parameter.
   *
   * @method ecttxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The flags value object
   * @return {Object} svg group container
   */
  TabularProfile.prototype.ecttxt = function (paper, p, center, txt, layer) {
    var content = paper.g(), style = {}, text;

    if (layer.character === 'P')
      style = {
        'fill': layer.value <= 30 ? '#d00' : p.font_color,
        'font-weight': layer.value <= 21 ? 'bold' : 'normal'
      };

    text = layer.text;
    if (Common.defaults.ECT_format === 'Swiss') text = layer.swisscode || layer.text;

    content.add(paper.text(center.x, center.y, text).attr(txt).attr(style));
    return content;
  };

  /**
   * Render the cell text for the Rutschblock parameter.
   *
   * @method rbtxt
   * @private
   * @param {Object} paper SnapSVG paper
   * @param {Object} p Properties object
   * @param {Object} center Pixel coordinates used for positioning
   * @param {Object} txt Font object
   * @param {Value} layer The flags value object
   * @return {Object} svg group container
   */
  TabularProfile.prototype.rbtxt = function (paper, p, center, txt, layer) {
    var content = paper.g(), string = '', style = {}, y = center.y;

    if (layer.releasetype === 'WB') style = { 'fill': '#d00' };
    if (layer.value <= 4 && layer.value > 0)
      style = {
        'fill': '#d00',
        'font-weight': layer.releasetype === 'WB' ? 'bold' : 'normal'
      };

    content.add(paper.text(center.x, center.above, layer.text).attr(txt).attr(style));

    if (layer.releasetype) string += t(layer.releasetype) + '\n';
    if (layer.character) string += t(layer.character);

    if (layer.top) {
      var text = paper.multitext(center.x, y, string);
      text.attr(txt).attr('font-size', p.fontsize - 2);
      content.add(text);
    }

    return content;
  };

  /**
   * This method enables the mousemove event on the area spanning
   * the  table.
   *
   * @method mouseon
   * @private
   */
  TabularProfile.prototype.mouseon = function () {
    var self = this, canvas = this.canvas, last, offset, current;

    this.mat.mousemove(function (e) {
      clearTimeout(self.timer);
      self.timer = setTimeout( function () {
        offset = e.pageY - canvas.offset().top;
        self.highlight(offset);

        if (self.bargraph) {
          current = self.bargraph.index.call(self.bargraph, self.index(offset));
          last = current ?
            self.bargraph.highlight.call(self.bargraph, last, current) :
            self.bargraph.unhighlight.call(self.bargraph, last);
        }
      }, 5);
    });

    this.mat.dblclick(function () {
      if (current && self.dblclickhandler) self.dblclickhandler(current);
    });

    this.mat.mouseout(function () {
      clearTimeout(self.timer);
      setTimeout( function () {
        self.unhighlight();
        if (self.bargraph) self.bargraph.unhighlight.call(self.bargraph, last);
      }, 30);
    });
  };

  /**
   * This method returns the reference to the blanket object that spans the
   * given y-coordinate - or undefined if no table row matches the y-coordinate.
   *
   * @method index
   * @private
   * @param {Number} y y-coordinate as pixel value
   * @return {Number} key of this.blanket object to reference actual object
   */
  TabularProfile.prototype.index = function (y) {
    var blanket = this.blanket;

    for (var key in blanket) {
      if (blanket.hasOwnProperty(key) && blanket[key].area) {
        var area = blanket[key].area,
          upper = area.top, lower = area.bottom;

        if (y >= upper && y < lower) return key;
      }
    }

    return undefined;
  };

  /**
   * Remove any highlights and the snow height label, if there is one.
   * @method unhighlight
   * @private
   */
  TabularProfile.prototype.unhighlight = function () {
    if (!this.activekey || !this.blanket) return;
    var blanket = this.blanket[this.activekey];

    blanket.elements.forEach(function (e) { e.attr({'stroke-width': 0.5}); });
    blanket.connector.attr(
      {
        strokeDasharray: '1,1',
        strokeWidth: 0.5
      });
    if (blanket.rect) blanket.rect.remove();
    this.activekey = undefined;
    if (this.hs) this.hs();
  };

  /**
   * Highlight the table row that spans the passed y-coordinate pixel.
   * Draw snow height label (this.hs()), if applicable.
   *
   * @method highlight
   * @private
   * @param {Number} y y-coordinate as pixel value
   */
  TabularProfile.prototype.highlight = function (y) {
    var p = this.properties, blanket = this.blanket, key = this.index(y);

    if ((!key && this.activekey) ||
        (key && this.activekey && key !== this.activekey)) {
      this.unhighlight();
    }

    if (key && key !== this.activekey) {
      var area = blanket[key].area;
      var upper = area.top, lower = area.bottom;

      blanket[key].elements.forEach(function (e) { e.attr({'stroke-width': 2}); });
      blanket[key].connector.attr(
        {
          'stroke-dasharray': '',
          'stroke-width': 2
        });

      blanket[key].rect = this.paper
        .rect(p.table.origin.x + .5, upper + 1, p.table.bwidth, lower - upper - .5)
        .attr({fill: 'blue', fillOpacity: 0.1, stroke: 'none', pointerEvents: 'none'});

      this.activekey = key;
      if (this.hs) this.hs(key);
    }
  };

  /**
   * Get the pixel value for a given snow height.
   *
   * @method y
   * @private
   * @param {Number} y Snow height
   * @return {Number} Pixel value
   */
  TabularProfile.prototype.y = function (y) {
    // if (this.range)
    //   console.dir('y(' + y + ') requested, range: ' + this.range.axis.min + ' / ' + this.range.axis.max);

    if (this.range) return this.range.axis.pixel(y);
    var p = this.properties.table;
    return Math.round(p.height + p.top - p.height / p.range * (y - p.min));
  };

  // --- Helpers ---

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

}(niviz, moment));