Source: meter-charts.js

/**
 * Plot timeseries from meter measurements.
 *
 * @module meter-charts
 */

var moment = require('moment');

var charts = require('./charts');

var model = require('./model');

  
var plotOptions = $.extend(true, {}, charts.plotOptions, {
  defaults: {
    colors: ['#2D3580', '#C53A3A', '#1DA809'],
  }
});

var filterForData = function (level)
{
  var [y0, y1] = level.range;
  return (v) => (
    (v.value != null) && (v.value >= y0) && (v.value < y1)
  );
};

var computeDataRange = function (series) {
  return series.reduce(([y0, y1], s, i) => {
    var values = s.data.map(t => t.value);
    return [
      Math.min(y0, Math.min.apply(null, values)),
      Math.max(y1, Math.max.apply(null, values)),
    ];
  }, [+Infinity, -Infinity]);
};

var computeTimeRange = function (series) {
  return series.reduce(([t0, t1], s, i) => {
    return [
      Math.min(t0, s.data[0].timestamp.getTime()),
      Math.max(t1, s.data[s.data.length - 1].timestamp.getTime()),
    ];
  }, [+Infinity, 0]);
};

var computeStep = function (series) {
  // The step for this collection of series is the minimum step
  return Math.min.apply(null, series.map(s => {
    // Compute step for this series (must be uniform!)
    var steps = s.data.map((p, i, data) => (
      (i > 0)? (data[i].timestamp - data[i - 1].timestamp) : null
    ));
    return Math.min.apply(null, steps.slice(1));
  })); 
};

/**
 * Plot timeseries as a bar (column) chart.
 *
 * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
 * 
 * @param {array} series - The series to be plotted. Each item contains data and per-series options.
 * @param {array} series.0.data - An array of {@linkcode Measurement} objects  
 * @param {string} series.0.color - The RGB color for columns.
 * @param {array} series.0.levels - An array that defines levels (zones) for values.
 * @param {array} series.0.levels.0.range - An array of 2 values that defines a numeric range
 * @param {string} series.0.levels.0.color - An RGB color to be used for values falling into the range of this level
 * @param {string} series.0.levels.0.description - A textual description for this level
 * 
 * @param {string} granularity - The time granularity. 
 *   One of `auto`, `minute`, `hour`, `day`, `week`, `month`.
 *
 * @param {object} config - An dict of options that further configure plotting.  
 * @param {number} config.bars.widthRatio - A number in (0, 1). The total bar width will cover this ratio of a category bucket.
 */ 
function plotAsBars($placeholder, series, granularity='auto', config={})
{
  if (!series || series.length == 0)
    return null;
  
  var data0 = series[0].data; // pilot data
  var M = data0[0].constructor; // class of measurements
  var [miny, maxy] = computeDataRange(series); 
  var [ts, te] = computeTimeRange(series); 
  var step = computeStep(series);

  config = $.extend(
    true, //deep
    {
      bars: {
        widthRatio: 0.50, // as part of bucket
      }, 
      xaxis: {}, 
      yaxis: {},
    }, 
    config
  );
  
  var {xaxis: {tickSize, tickFilter, formatter: formatTime}} = config;
 
  var getPoint = (granularity == 'auto')? 
    (p) => ([moment(p.timestamp).diff(ts) / step, p.timestamp, p.value]):
    (p) => ([moment(p.timestamp).diff(ts, granularity), p.timestamp, p.value]);
  
  var getDataPoint = (p) => {
    var [x, t, y] = getPoint(p);
    return [x, y];
  };

  var maxx = (granularity == 'auto')?
    (moment(te).diff(ts) / step):
    (moment(te).diff(ts, granularity));

  // Compute ticks on X axis
  
  var tickPoints = data0;
  if ($.isNumeric(tickSize) && tickSize > 1)
    tickPoints = data0.filter((v, i) => (i % tickSize == 0));
  else if ($.isFunction(tickFilter))
    tickPoints = data0.filter((v) => tickFilter(v.timestamp));
  tickPoints = tickPoints.map(getPoint);

  // Center tick position when flot-orderBars is not engaged
  var tickOffset = (series.length == 1)? (config.bars.widthRatio * 0.5) : 0.0;

  // Compute Flot options
  
  var options = {
    series: {
      points: {show: false},
      shadowSize: 0,
      lines: {show: false},
      bars: $.extend({show: true}, plotOptions.defaults.series.bars),
    },
    xaxis: $.extend({}, plotOptions.defaults.xaxis, {
      ticks: tickPoints
        .map(([x, t, y]) => ([x + tickOffset, formatTime(t)])),
      min: null, // let Flot compute it, so that 1st bar shows up
      max: maxx + 1,
    }),
    yaxis: $.extend({}, plotOptions.defaults.yaxis, {
      ticks: charts.generateTicks([.0, maxy], 4, config.yaxis.tickUnit),
      min: .0,
      max: maxy + 0.20 * (maxy - miny),
    }),
    grid: plotOptions.defaults.grid,
    legend: {
      show: false, 
      position: 'ne'
    },
    bars: $.extend({}, plotOptions.defaults.bars, {
      barWidth: config.bars.widthRatio / series.length
    }),
  };
 
  // Transform to series data understood by Flot 
  // Order bars (flot-orderBars plugin) by their series number
  
  series = series.map((s, i) => {
    if ($.isArray(s.levels))
      return s.levels.map((level) => ({
        data: s.data.filter(filterForData(level)).map(getDataPoint),
        label: M.formatLabel() + ' (' + level.description + ')',
        color: level.color,
        bars: {order: i},
      }));
    else
      return {
        data: s.data.map(getDataPoint),
        label: M.formatLabel(),
        color: s.color || plotOptions.defaults.colors[i],
        bars: {order: i},
      };
  });

  // Flatten by 1 level
  series = [].concat(...series);
  
  return $.plot($placeholder, series, options);
};

/**
 * Plot timeseries as line/area charts.
 *
 * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
 * 
 * @param {array} series - The series to be plotted. Each item contains data and per-series options.
 * @param {array} series.0.data - An array of {@linkcode Measurement} objects  
 * @param {number} series.0.fill - The alpha level of the are below the line. If <code>null</code> is given, then the area is not plotted. 
 * @param {string} series.0.line - The style of plotted line. One of `solid`, `dashed`.
 * @param {string} series.0.color - The RGB color of this line.
 *
 * @param {string} granularity - The time granularity. 
 *   One of `auto`, `minute`, `hour`, `day`, `week`, `month`.   
 *
 * @param {object} config - An dict of options that further configure plotting.
 *
 *
 */ 
function plotAsLines($placeholder, series, granularity='auto', config={})
{
  if (!series || series.length == 0)
    return null;

  var data0 = series[0].data; // pilot data
  var M = data0[0].constructor; // class of measurements
  var [miny, maxy] = computeDataRange(series); 
  var [ts, te] = computeTimeRange(series); 
  var step = computeStep(series);

  config = $.extend(
    true, //deep
    {
      xaxis: {}, 
      yaxis: {},
      lines: {fill: null},
    }, 
    config
  );
    
  var {xaxis: {tickSize, tickFilter, formatter: formatTime}} = config;
  
  var getPoint = (granularity == 'auto')? 
    (p) => ([moment(p.timestamp).diff(ts) / step, p.timestamp, p.value]):
    (p) => ([moment(p.timestamp).diff(ts, granularity), p.timestamp, p.value]);
  
  var getDataPoint = (p) => {
    var [x, t, y] = getPoint(p);
    return [x, y];
  };

  var maxx = (granularity == 'auto')?
    (moment(te).diff(ts) / step):
    (moment(te).diff(ts, granularity));

  //Compute ticks on X axis
  
  var tickPoints = data0; 
  if ($.isNumeric(tickSize) && tickSize > 1)
    tickPoints = data0.filter((v, i) => (i % tickSize == 0));
  else if ($.isFunction(tickFilter))
    tickPoints = data0.filter((v) => tickFilter(v.timestamp));
  tickPoints = tickPoints.map(getPoint);
  
  // Compute Flot options
  
  var options = {
    series: {
      points: $.extend({}, plotOptions.defaults.series.points, {
        show: true,
      }),
      shadowSize: 0,
      lines: $.extend({}, plotOptions.defaults.series.lines, {
        show: true,
      }),
      dashes: $.extend({}, plotOptions.defaults.series.dashes),
    },
    xaxis: $.extend({}, plotOptions.defaults.xaxis, {
      ticks: tickPoints
        .map(([x, t, y]) => ([x, formatTime(t)])),
      min: 0,
      max: maxx + 1,
    }),
    yaxis: $.extend({}, plotOptions.defaults.yaxis, {
      ticks: charts.generateTicks([.0, maxy], 4, config.yaxis.tickUnit),
      min: .0,
      max: maxy + 0.20 * (maxy - miny),
    }),
    grid: plotOptions.defaults.grid,
    legend: {show: false},
  };
    
  // Transform series to the shape undestood by Flot
  series = series.map((s, i) => ({
    data: s.data.filter(t => t.value).map(getDataPoint),
    label: M.formatLabel(),
    color: s.color || plotOptions.defaults.colors[i],
    lines: {
      show: (s.line == null || s.line == 'solid'),
      fill: s.fill === undefined?  config.lines.fill : s.fill,  
    },
    dashes: {
      show: (s.line == 'dashed'),
    },
  }));
    
  return $.plot($placeholder, series, options);
};

charts.meter = module.exports = {
  
  /**
   * Plot series for a certain day.
   * 
   * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
   * 
   * @param {array} series - The series to be plotted. Each item contains data and per-series options.
   *   See <a href="#~plotAsBars">plotAsBars</a>. 
   */ 
  plotForDay: function ($placeholder, series, config={}, locale=null)
  {
    var formatter = locale?
      (t) => (moment(t).locale(locale).format('ha')):
      (t) => (moment(t).format('ha'));

    var granularity = 'hour';

    config = $.extend(
      true,
      {
        bars: {
          widthRatio: 0.50,
        },
        xaxis: {
          tickSize: 4, // 1 tick every tickSize datapoints (hours)
          formatter,
        },
      },
      config
    );

    return plotAsBars($placeholder, series, granularity, config); 
  },
  
  /**
   * Plot series for a certain week.
   *
   * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
   * 
   * @param {array} series - The series to be plotted. Each item contains data and per-series options.
   *   See <a href="#~plotAsBars">plotAsBars</a>. 
   */ 
  plotForWeek: function ($placeholder, series, config, locale=null) 
  {
    var formatter = locale?
      (t) => (moment(t).locale(locale).format('dd')):
      (t) => (moment(t).format('dd'));
    
    var granularity = 'day';

    config = $.extend(
      true,
      {
        bars: {
          widthRatio: 0.50,
        },
        xaxis: {
          tickSize: 1, // 1 tick every tickSize datapoints (days)
          formatter,
        },
      },
      config
    );

    return plotAsBars($placeholder, series, granularity, config);
  },
  
  /**
   * Plot series for a certain month.
   *
   * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
   * 
   * @param {array} series - The series to be plotted. Each item contains data and per-series options.
   *   See <a href="#~plotAsLines">plotAsLines</a>. 
   */ 
  plotForMonth: function ($placeholder, series, config, locale=null)
  {
    var formatter = locale?
      (t) => (moment(t).locale(locale).format('dd D MMM')):
      (t) => (moment(t).format('dd D MMM'));
    
    var tickFilter = (t) => (
      moment(t).diff(moment(t).startOf('isoweek'), 'day') == 0
    );
    
    var granularity = 'day';
    
    config = $.extend(
      true,
      {
        lines: {
          fill: null,
        },
        xaxis: {
          //tickSize: 7, // 1 tick every tickSize datapoints (days)
          tickFilter,
          formatter,
        },
      },
      config
    );

    return plotAsLines($placeholder, series, granularity, config);
  },
  
  /**
   * Plot series for a certain year.
   *
   * @param {jQuery} $placeholder - The DOM element (as a jQuery result) to hold the chart.
   * 
   * @param {array} series - The series to be plotted. Each item contains data and per-series options.
   *   See <a href="#~plotAsLines">plotAsLines</a>. 
   */ 
  plotForYear: function ($placeholder, series, config, locale=null)
  {
    var formatter = locale?
      (t) => (moment(t).locale(locale).format('MMM')):
      (t) => (moment(t).format('MMM'));
    
    var granularity = 'month';

    config = $.extend(
      true,
      {
        lines: {
          fill: null,
        },
        xaxis: {
          tickSize: 1, // 1 tick every tickSize datapoints (month)
          formatter,
        },
      },
      config
    );

    return plotAsLines($placeholder, series, granularity, config);
  },
};

module.exports = charts.meter;