widgets/graph.js

/*
 * This file is part of AUX.
 *
 * AUX is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * AUX 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
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General
 * Public License along with this program; if not, write to the
 * Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
 * Boston, MA  02110-1301  USA
 */

/* jshint -W014 */
/* jshint -W086 */

import { defineRange } from '../utils/define_range.js';
import { Widget } from './widget.js';
import { toggleClass, addClass } from '../utils/dom.js';
import { makeSVG } from '../utils/svg.js';
import { error } from '../utils/log.js';
import { defineRender } from '../renderer.js';

// this is not really a rounding operation but simply adds 0.5. we do this to make sure
// that integer pixel positions result in actual pixels, instead of being spread across
// two pixels with half opacity
function SVGRound(x) {
  x = +x;
  return x + 0.5;
}
function getPixels(value, range) {
  return SVGRound(range.valueToPixel(value));
}

function _start(d, s) {
  const h = this.range_y.options.basis;
  const m = this.options.mode;
  const x = this.range_x.valueToPixel(d[0].x);
  const y = this.range_y.valueToPixel(d[0].y);

  let fillY;

  switch (m) {
    case 'bottom':
      fillY = h + 1;
      break;
    case 'top':
      fillY = -1;
      break;
    case 'center':
      fillY = h / 2;
      break;
    case 'base':
      fillY = (1 - this.options.base) * h;
      break;
    default:
      error('Unsupported mode:', m);
    /* FALL THROUGH */
    case 'fill':
    /* FALL THROUGH */
    case 'line':
      // fill nothing
      s.push('M ' + SVGRound(x) + ' ' + SVGRound(y));
      return;
  }

  s.push(
    'M ' + SVGRound(x - 1) + ' ' + SVGRound(fillY) + ' ',
    'L ' + SVGRound(x - 1) + ' ' + SVGRound(y) + ' '
  );
}

function _end(d, s) {
  const dot = d[d.length - 1];
  const h = this.range_y.options.basis;
  const m = this.options.mode;

  let fillY;

  switch (m) {
    case 'bottom':
      fillY = h + 1;
      break;
    case 'top':
      fillY = -1;
      break;
    case 'center':
      fillY = h / 2;
      break;
    case 'base':
      fillY = (1 - this.options.base) * h;
      break;
    case 'fill':
      s.push(' Z');
      return;
    default:
      error('Unsupported mode:', m);
    /* FALL THROUGH */
    case 'line':
      return;
  }

  const x = this.range_x.valueToPixel(dot.x);
  const y = this.range_y.valueToPixel(dot.y);

  s.push(
    ' L ' + SVGRound(x + 1) + ' ' + SVGRound(y),
    ' L ' + SVGRound(x + 1) + ' ' + SVGRound(fillY),
    ' Z'
  );
}

/**
 * Graph is a single SVG path element. It provides
 * some functions to easily draw paths inside Charts and other
 * derivates.
 *
 * @class Graph
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Function|Object} options.range_x - Callback function
 *   returning a {@link Range} module for x axis or an object with options
 *   for a new {@link Range}.
 * @property {Function|Object} options.range_y - Callback function
 *   returning a {@link Range} module for y axis or an object with options
 *   for a new {@link Range}.
 * @property {Array<Object>|String} options.dots=[] - The dots of the path.
 *   Can be a ready-to-use SVG-path-string or an array of objects like
 *   <code>{x: x, y: y [, x1: x1, y1: y1, x2: x2, y2: y2, type: type]}</code> (depending on the type,
 *   see `options.type` for more information). `type` is optional and defines a different type
 *   as explained under `options.type` for a specific dot. If omitted, the
 *   general `options.type` is used.
 *   It may also be a function, in which case it is called with this graph
 *   widget as first and only argument. The return value can be one of the
 *   other possible types.
 * @property {String} [options.type="L"] - Type of the graph (needed values in dots object):
 *   <ul>
 *     <li><code>L</code>: normal (needs x,y)</li>
 *     <li><code>T</code>: smooth quadratic Bézier (needs x, y)</li>
 *     <li><code>H[n]</code>: smooth horizontal, [n] = smoothing factor between 1 (square) and 5 (nearly no smoothing)</li>
 *     <li><code>Q</code>: quadratic Bézier (needs: x1, y1, x, y)</li>
 *     <li><code>C</code>: CurveTo (needs: x1, y1, x2, y2, x, y)</li>
 *     <li><code>S</code>: SmoothCurve (needs: x1, y1, x, y)</li>
 *   </ul>
 * @property {String} [options.mode="line"] - Drawing mode of the graph, possible values are:
 *   <ul>
 *     <li><code>line</code>: line only</li>
 *     <li><code>bottom</code>: fill below the line</li>
 *     <li><code>top</code>: fill above the line</li>
 *     <li><code>center</code>: fill from the vertical center of the canvas</li>
 *     <li><code>base</code>: fill from a arbitray position on the canvas (set with base)</li>
 *     <li><code>fill</code>: close the curve using a Z directive and fill on the canvas</li>
 *   </ul>
 * @property {Number} [options.base=0] - If mode is <code>base</code> set the position
 *   of the base line to fill from between 0 (bottom) and 1 (top).
 * @property {String} [options.color=""] - Set the color of the path.
 *   Better use <code>stroke</code> and <code>fill</code> via CSS.
 * @property {String|Boolean} [options.key=false] - Show a description
 *   for this graph in the charts key, <code>false</code> to turn it off.
 *
 * @extends Widget
 */
export class Graph extends Widget {
  static get _options() {
    return {
      dots: 'array',
      type: 'string',
      mode: 'string',
      base: 'number',
      color: 'string',
      range_x: 'object',
      range_y: 'object',
      key: 'string|boolean',
    };
  }

  static get options() {
    return {
      dots: null,
      type: 'L',
      mode: 'line',
      base: 0,
      color: '',
      key: false,
    };
  }

  static get renderers() {
    return [
      defineRender('color', function (color) {
        this.element.style.stroke = color;
      }),
      defineRender('mode', function (mode) {
        const element = this.element;
        const filled = mode !== 'line';
        toggleClass(element, 'aux-filled', filled);
        toggleClass(element, 'aux-outline', !filled);
      }),
      defineRender(['dots', 'type', 'mode', 'range_x', 'range_y'], function (
        dots,
        type,
        mode,
        range_x,
        range_y
      ) {
        let path;

        if (typeof dots === 'function') {
          dots = dots(this);
        }

        if (typeof dots === 'string') {
          path = dots;
        } else if (!dots) {
          path = '';
        } else if (Array.isArray(dots)) {
          // if we are drawing a line, _start will do the first point
          let i = mode === 'line' ? 1 : 0;
          const s = [];

          if (dots.length > 0) {
            _start.call(this, dots, s);
          }

          if (i === 0 && (dots[i].type || type).startsWith('H')) {
            i++;
            const dot = dots[i];
            const X = getPixels(dot.x, range_x);
            const Y = getPixels(dot.y, range_y);

            s.push(' S' + X + ',' + Y + ' ' + X + ',' + Y);
          }

          for (; i < dots.length; i++) {
            const dot = dots[i];
            const _type = dot.type || type;
            const t = _type.substring(0, 1);

            switch (t) {
              case 'L':
              case 'T': {
                const X = getPixels(dot.x, range_x);
                const Y = getPixels(dot.y, range_y);

                s.push(' ' + t + ' ' + X + ' ' + Y);
                break;
              }
              case 'Q':
              case 'S': {
                const X = getPixels(dot.x, range_x);
                const X1 = getPixels(dot.x1, range_x);
                const Y = getPixels(dot.y, range_y);
                const Y1 = getPixels(dot.y1, range_y);

                s.push(' ' + t + ' ' + X1 + ',' + Y1 + ' ' + X + ',' + Y);
                break;
              }
              case 'C': {
                const X = getPixels(dot.x, range_x);
                const X1 = getPixels(dot.x1, range_x);
                const X2 = getPixels(dot.x2, range_x);
                const Y = getPixels(dot.y, range_y);
                const Y1 = getPixels(dot.y1, range_y);
                const Y2 = getPixels(dot.y2, range_y);

                s.push(
                  ' ' +
                    t +
                    ' ' +
                    X1 +
                    ',' +
                    Y1 +
                    ' ' +
                    X2 +
                    ',' +
                    Y2 +
                    ' ' +
                    X +
                    ',' +
                    Y
                );
                break;
              }
              case 'H': {
                const f = _type.length > 1 ? parseFloat(type.substring(1)) : 3;
                const X = getPixels(dot.x, range_x);
                const Y = getPixels(dot.y, range_y);
                const X1 = getPixels(
                  dot.x - Math.round(dot.x - dots[i - 1].x) / f,
                  range_x
                );

                s.push(' S ' + X1 + ',' + Y + ' ' + X + ',' + Y);
                break;
              }
              default:
                error('Unsupported graph type', _type);
            }
          }

          if (dots.length > 0) {
            _end.call(this, dots, s);
          }

          path = s.join('');
        } else {
          error('Unsupported "dots" type', dots);
          path = '';
        }

        this.element.setAttribute('d', path);
      }),
    ];
  }

  initialize(options) {
    if (!options.element) options.element = makeSVG('path');
    super.initialize(options);
    /** @member {SVGPath} Graph#element - The SVG path. Has class <code>.aux-graph</code>
     */
    /** @member {Range} Graph#range_x - The range for the x axis.
     */
    /** @member {Range} Graph#range_y - The range for the y axis.
     */
    defineRange(this, this.options.range_x, 'range_x');
    defineRange(this, this.options.range_y, 'range_y');

    this.set('color', this.options.color);
    this.set('mode', this.options.mode);
  }

  draw(O, element) {
    addClass(element, 'aux-graph');

    super.draw(O, element);
  }

  /**
   * Moves the graph to the front, i.e. add as last element to the containing
   * SVG group element.
   *
   * @method Graph#toFront
   */
  toFront() {
    const E = this.element;
    const P = E.parentElement;
    if (P && E !== P.lastChild)
      this.drawOnce(function () {
        const e = this.element;
        const _p = e.parentNode;
        if (_p && e !== _p.lastChild) _p.appendChild(e);
      });
  }

  /**
   * Moves the graph to the back, i.e. add as first element to the containing
   * SVG group element.
   *
   * @method Graph#toBack
   */
  toBack() {
    const E = this.element;
    const P = E.parentElement;
    if (P && E !== P.firstChild)
      this.drawOnce(function () {
        const e = this.element;
        const _p = e.parentNode;
        if (_p && e !== _p.firstChild) _p.insertBefore(e, _p.firstChild);
      });
  }
}