widgets/chart.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
 */

import { defineChildElement } from './../widget_helpers.js';
import {
  CSSSpace,
  getStyle,
  element,
  addClass,
  innerWidth,
  innerHeight,
} from '../utils/dom.js';
import { defineRange } from '../utils/define_range.js';
import { makeSVG } from '../utils/svg.js';
import { error, warn } from '../utils/log.js';
import { Widget, SymResize } from './widget.js';
import { Graph } from './graph.js';
import { ChartHandle } from './charthandle.js';
import { defineChildWidget } from '../child_widget.js';
import { Grid } from './grid.js';
import {
  defineRender,
  defineMeasure,
  deferRender,
  deferMeasure,
} from '../renderer.js';
import { ChildWidgets } from '../utils/child_widgets.js';

function calculateOverlap(X, Y) {
  /* no overlap, return 0 */
  if (X[2] < Y[0] || Y[2] < X[0] || X[3] < Y[1] || Y[3] < X[1]) return 0;

  return (
    (Math.min(X[2], Y[2]) - Math.max(X[0], Y[0])) *
    (Math.min(X[3], Y[3]) - Math.max(X[1], Y[1]))
  );
}

function STOP(e) {
  e.preventDefault();
  e.stopPropagation();
  return false;
}

const SymLabelChanged = Symbol('_label changed');
const SymGraphs = Symbol('graphs changed');

/**
 * Chart is an SVG image containing one or more Graphs. Chart
 * extends {@link Widget} and contains a {@link Grid} and two
 * {@link Range}s.
 *
 * @class Chart
 * @extends Widget
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {String|Boolean} [options.label=""] - A label for the Chart.
 *   Set to `false` to remove the label from the DOM.
 * @property {String} [options.label_position="top-left"] - Position of the
 *   label inside of the chart. Possible values are
 *   <code>"top-left"</code>, <code>"top"</code>, <code>"top-right"</code>,
 *   <code>"left"</code>, <code>"center"</code>, <code>"right"</code>,
 *   <code>"bottom-left"</code>, <code>"bottom"</code> and
 *   <code>"bottom-right"</code>.
 * @property {Array<Object>} [options.grid_x=[]] - An array containing
 *   objects with the following optional members to draw the grid:
 * @property {Number} [options.grid_x.pos] - The value where to draw  grid line and corresponding label.
 * @property {String} [options.grid_x.color] - A valid CSS color string to colorize the elements.
 * @property {String} [options.grid_x.class] - A class name for the elements.
 * @property {String} [options.grid_x.label] - A label string.
 * @property {Array<Object>} [options.grid_y=[]] - An array containing
 *   objects with the following optional members to draw the grid:
 * @property {Number} [options.grid_y.pos] - The value where to draw  grid line and corresponding label.
 * @property {String} [options.grid_y.color] - A valid CSS color string to colorize the elements.
 * @property {String} [options.grid_y.class] - A class name for the elements.
 * @property {String} [options.grid_y.label] - A label string.
 * @property {Boolean} [options.show_grid=true] - Set to <code>false</code> to
 *   hide the grid.
 * @property {Function|Object} [options.range_x={}] - Either a function
 *   returning a {@link Range} or an object containing options for a
 *   new {@link Range}.
 * @property {Function|Object} [options.range_y={}] - Either a function
 *   returning a {@link Range} or an object containing options for a
 *   new {@link Range}.
 * @property {Object|Function} [options.range_z={ scale: "linear", min: 0, max: 1 }] -
 *   Either a function returning a {@link Range} or an object
 *   containing options for a new {@link Range}.
 * @property {Number} [options.importance_label=4] - Multiplicator of
 *   square pixels on hit testing labels to gain importance.
 * @property {Number} [options.importance_handle=1] - Multiplicator of
 *   square pixels on hit testing handles to gain importance.
 * @property {Number} [options.importance_border=50] - Multiplicator of
 *   square pixels on hit testing borders to gain importance.
 * @property {Array<Object>} [options.handles=[]] - An array of options for
 *   creating {@link ChartHandle} on init.
 * @property {Array<Object>} [options.graphs=[]] - An array of options for
 *   creating {@link Graph} on init.
 * @property {Boolean} [options.show_handles=true] - Show or hide all
 *   handles.
 * @property {Boolean} [options.square=false] - Keep the Graph as a square.
 *
 */
function geomSet(value, key) {
  this.setStyle(key, value + 'px');
  error("using deprecated '" + key + "' options");
}
export class Chart extends Widget {
  static get _options() {
    return {
      width: 'int',
      height: 'int',
      _width: 'int',
      _height: 'int',
      range_x: 'object',
      range_y: 'object',
      range_z: 'object',
      label: 'string',
      label_position: 'string',
      resized: 'boolean',

      importance_label: 'number',
      importance_handle: 'number',
      importance_border: 'number',
      handles: 'array',
      graphs: 'array',
      show_handles: 'boolean',
      depth: 'number',
      square: 'boolean',
      grid_x: 'array',
      grid_y: 'array',
    };
  }

  static get options() {
    return {
      grid_x: [],
      grid_y: [],
      range_x: {}, // an object with options for a range for the x axis
      // or a function returning a Range instance (only on init)
      range_y: {}, // an object with options for a range for the y axis
      // or a function returning a Range instance (only on init)
      range_z: { scale: 'linear', min: 0, max: 1 }, // Range z options
      label: '', // a label for the chart
      label_position: 'top-left', // the position of the label
      resized: false,

      importance_label: 4, // multiplicator of square pixels on hit testing
      // labels to gain importance
      importance_handle: 1, // multiplicator of square pixels on hit testing
      // handles to gain importance
      importance_border: 50, // multiplicator of square pixels on hit testing
      // borders to gain importance
      handles: [], // list of bands to create on init
      graphs: [], // list of graphs to create on init
      show_handles: true,
      square: false,
      role: 'group',
    };
  }

  static get static_events() {
    return {
      set_width: geomSet,
      set_height: geomSet,

      mousewheel: STOP,
      DOMMouseScroll: STOP,
      set_depth: function (value) {
        this.range_z.set('basis', value);
      },
    };
  }

  static get renderers() {
    return [
      defineRender(['_width', '_height'], function (_width, _height) {
        const E = this.svg;

        if (_width && _height) {
          E.setAttribute('width', _width + 'px');
          E.setAttribute('height', _height + 'px');
        }
      }),
      defineRender(
        ['label', 'label_position', 'range_x', 'range_y', SymLabelChanged],
        function (label, label_position, range_x, range_y) {
          const _label = this._label;

          if (!_label) return;

          _label.textContent = label;

          return deferMeasure(() => {
            const mtop = parseInt(getStyle(_label, 'margin-top') || 0);
            const mleft = parseInt(getStyle(_label, 'margin-left') || 0);
            const mbottom = parseInt(getStyle(_label, 'margin-bottom') || 0);
            const mright = parseInt(getStyle(_label, 'margin-right') || 0);
            const { height } = _label.getBoundingClientRect();
            const xBasis = range_x.options.basis;
            const yBasis = range_y.options.basis;

            let x, y, anchor;

            switch (label_position) {
              case 'top-left':
                anchor = 'start';
                x = mleft;
                y = mtop + height / 2;
                break;
              case 'top':
                anchor = 'middle';
                x = xBasis / 2;
                y = mtop + height / 2;
                break;
              case 'top-right':
                anchor = 'end';
                x = xBasis - mright;
                y = mtop + height / 2;
                break;
              case 'left':
                anchor = 'start';
                x = mleft;
                y = yBasis / 2;
                break;
              case 'center':
                anchor = 'middle';
                x = xBasis / 2;
                y = yBasis / 2;
                break;
              case 'right':
                anchor = 'end';
                x = xBasis - mright;
                y = yBasis / 2;
                break;
              case 'bottom-left':
                anchor = 'start';
                x = mleft;
                y = yBasis - mtop - height / 2;
                break;
              case 'bottom':
                anchor = 'middle';
                x = xBasis / 2;
                y = yBasis - mbottom - height / 2;
                break;
              case 'bottom-right':
                anchor = 'end';
                x = xBasis - mright;
                y = yBasis - mbottom - height / 2;
                break;
              default:
                warn('Unsupported label_position', label_position);
            }

            return deferRender(() => {
              _label.setAttribute('text-anchor', anchor);
              _label.setAttribute('x', x);
              _label.setAttribute('y', y);
            });
          });
        }
      ),
      defineRender('show_handles', function (show_handles) {
        const style = this._handles.style;
        if (show_handles) {
          style.removeProperty('display');
        } else {
          style.display = 'none';
        }
      }),
      defineMeasure(['square', SymResize], function (square) {
        const E = this.element;
        const SVG = this.svg;

        const tmp = CSSSpace(SVG, 'border', 'padding');
        let w = innerWidth(E, undefined, true) - tmp.left - tmp.right;
        let h = innerHeight(E, undefined, true) - tmp.top - tmp.bottom;

        if (square) {
          w = h = Math.min(h, w);
        }

        this.set('_width', w);
        this.range_x.set('basis', w);
        this.set('_height', h);
        this.range_y.set('basis', h);
      }),
    ];
  }

  initialize(options) {
    let SVG;
    /**
     * @member {Array} Chart#handles - An array containing all {@link ChartHandle} instances.
     */
    if (!options.element) options.element = element('div');
    super.initialize(options);

    /**
     * @member {Range} Chart#range_x - The {@link Range} for the x axis.
     */
    /**
     * @member {Range} Chart#range_y - The {@link Range} for the y axis.
     */
    defineRange(this, this.options.range_x, 'range_x');
    defineRange(this, this.options.range_y, 'range_y');
    defineRange(this, this.options.range_z, 'range_z');
    this.range_y.set('reverse', true);

    /**
     * @member {HTMLDivElement} Chart#element - The main DIV container.
     *   Has class <code>.aux-chart</code>.
     */

    this.svg = SVG = makeSVG('svg');

    if (!this.options.width) this.options.width = this.range_x.options.basis;
    if (!this.options.height) this.options.height = this.range_y.options.basis;

    /**
     * @member {SVGGroup} Chart#_graphs - The SVG group containing all graphs.
     *      Has class <code>.aux-graphs</code>.
     */
    this._graphs = makeSVG('g', { class: 'aux-graphs' });
    SVG.appendChild(this._graphs);

    if (this.options.width) this.set('width', this.options.width);
    if (this.options.height) this.set('height', this.options.height);

    /**
     * @member {SVGGroup} Chart#_handles - The SVG group containing all handles.
     *      Has class <code>.aux-handles</code>.
     */
    this._handles = makeSVG('g', { class: 'aux-handles' });
    SVG.appendChild(this._handles);
    SVG.onselectstart = function () {
      return false;
    };
    this._graphChildren = new ChildWidgets(this, {
      filter: Graph,
    });
    this._graphChildren.forEachAsync((graph) => {
      graph.set('range_x', this.range_x);
      graph.set('range_y', this.range_y);
      this._graphs.appendChild(graph.element);

      /**
       * Is fired when a graph was added. Arguments are the graph
       * and its position in the array.
       *
       * @event Chart#graphadded
       *
       * @param {Graph} graph - The {@link Graph} which was added.
       */
      this.emit('graphadded', graph);

      const sub = graph.subscribe('set', (key) => {
        if (key === 'color' || key === 'class' || key === 'key')
          this.invalidate(SymGraphs);
      });

      this.invalidate(SymGraphs);

      return () => {
        sub();
        /**
         * Is fired when a graph was removed. Arguments are the graph
         * and its position in the array.
         *
         * @event Chart#graphremoved
         *
         * @param {Graph} graph - The {@link Graph} which was removed.
         */
        this.emit('graphremoved', graph);
      };
    });
    this.addHandles(this.options.handles);
    this.addGraphs(this.options.graphs);
  }

  draw(O, element) {
    addClass(element, 'aux-chart');
    element.appendChild(this.svg);

    super.draw(O, element);
  }

  getResizeTargets() {
    return [this.element, this.svg];
  }

  getGraphs() {
    return this._graphChildren.getList();
  }

  getHandles() {
    return this.getChildren().filter((child) => child instanceof ChartHandle);
  }

  destroy() {
    this._graphs.remove();
    this._handles.remove();
    this.svg.remove();
    super.destroy();
  }

  addChild(child) {
    super.addChild(child);

    if (child instanceof ChartHandle) {
      child.set('intersect', this.intersect.bind(this));
      child.set('range_x', () => this.range_x);
      child.set('range_y', () => this.range_y);
      child.set('range_z', () => this.range_z);
      this._handles.appendChild(child.element);
      /**
       * Is fired when a new handle was added.
       *
       * @param {ChartHandle} handle - The {@link ChartHandle} which was added.
       *
       * @event Chart#handleadded
       */
      this.emit('handleadded', child);
    }
  }

  removeChild(child) {
    if (this.isDestructed()) return;
    super.removeChild(child);

    if (child instanceof ChartHandle) {
      const childElement = child.element;
      const _handles = this._handles;

      if (childElement.parentNode === _handles) childElement.remove();

      /**
       * Is fired when a handle was removed.
       *
       * @event Chart#handleremoved
       */
      this.emit('handleremoved', child);
    }
  }

  /**
   * Add a graph to the chart.
   *
   * @method Chart#addGraph
   *
   * @param {Object} graph - The graph to add. This can be either an
   *  instance of {@link Graph} or an object of options to
   *  {@link Graph}.
   *
   * @returns {Object} The instance of {@link Graph}.
   *
   * @emits Chart#graphadded
   */
  addGraph(options) {
    let g;

    if (options instanceof Graph) {
      g = options;
    } else {
      g = new Graph(options);
    }

    this.addChild(g);

    return g;
  }

  /**
   * Add multiple new {@link Graph} to the widget. Options is an array
   * of objects containing options for the new instances of {@link Graph}.
   *
   * @method Chart#addGraphs
   *
   * @param {Array<Object>} options - An array of options objects for the {@link Graph}.
   */
  addGraphs(graphs) {
    for (let i = 0; i < graphs.length; i++) this.addGraph(graphs[i]);
  }

  /**
   * Remove a graph from the chart.
   *
   * @method Chart#removeGraph
   *
   * @param {Graph} graph - The {@link Graph} to remove.
   *
   * @emits Chart#graphremoved
   */
  removeGraph(g) {
    this.removeChild(g);
  }

  /**
   * Remove all graphs from the chart.
   *
   * @method Chart#empty
   *
   * @emits Chart#emptied
   */
  empty() {
    this.getGraphs().forEach((graph) => this.removeChild(graph));
    /**
     * Is fired when all graphs are removed from the chart.
     *
     * @event Chart#emptied
     */
    this.emit('emptied');
  }

  /**
   * Add a new handle to the widget. Options is an object containing
   * options for the {@link ChartHandle}.
   *
   * @method Chart#addHandle
   *
   * @param {Object} [options={ }] - An object containing initial options. - The options for the {@link ChartHandle}.
   * @param {Object} [type=ChartHandle] - A widget class to be used as the new handle.
   *
   * @emits Chart#handleadded
   */
  addHandle(options, type) {
    let handle;

    if (options instanceof ChartHandle) {
      handle = options;
    } else {
      type = type || ChartHandle;
      handle = new type(options);
    }

    this.addChild(handle);

    return handle;
  }

  /**
   * Add multiple new {@link ChartHandle} to the widget. Options is an array
   * of objects containing options for the new instances of {@link ChartHandle}.
   *
   * @method Chart#addHandles
   *
   * @param {Array<Object>} options - An array of options objects for the {@link ChartHandle}.
   * @param {Object} [type=ChartHandle] - A widget class to be used for the new handles.
   */
  addHandles(handles, type) {
    for (let i = 0; i < handles.length; i++) this.addHandle(handles[i], type);
  }

  /**
   * Remove a handle from the widget.
   *
   * @method Chart#removeHandle
   *
   * @param {ChartHandle} handle - The {@link ChartHandle} to remove.
   *
   * @emits Chart#handleremoved
   */
  removeHandle(handle) {
    this.removeChild(handle);
  }

  /**
   * Remove multiple or all {@link ChartHandle} from the widget.
   *
   * @method Chart#removeHandles
   *
   * @param {Array<ChartHandle>} handles - An array of
   *   {@link ChartHandle} instances. If the argument reveals to
   *   `false`, all handles are removed from the widget.
   */
  removeHandles(handles) {
    if (!handles) handles = this.getHandles();

    handles.forEach((handle) => this.removeHandle(handle));

    if (!this.getHandles().length) {
      /**
       * Is fired when all handles are removed.
       *
       * @event Chart#emptied
       */
      this.emit('emptied');
    }
  }

  intersect(X, handle) {
    // this function walks over all known handles and asks for the coords
    // of the label and the handle. Calculates intersecting square pixels
    // according to the importance set in options. Returns an object
    // containing intersect (the amount of intersecting square pixels) and
    // count (the amount of overlapping elements)
    let c = 0;
    let a = 0,
      _a;
    const O = this.options;
    const importance_handle = O.importance_handle;
    const importance_label = O.importance_label;
    const handles = this.getHandles();

    for (let i = 0; i < handles.length; i++) {
      const h = handles[i];
      if (h === handle || !h.get('active') || !h.get('show_handle')) continue;
      _a = calculateOverlap(X, h.getHandlePosition());

      if (_a) {
        c++;
        a += _a * importance_handle;
      }

      _a = calculateOverlap(X, h.label);

      if (_a) {
        c++;
        a += _a * importance_label;
      }
    }
    if (this.bands && this.bands.length) {
      for (let i = 0; i < this.bands.length; i++) {
        const b = this.bands[i];
        if (b === handle || !b.get('active') || !b.get('show_handle')) continue;
        _a = calculateOverlap(X, b.getHandlePosition());

        if (_a > 0) {
          c++;
          a += _a * importance_handle;
        }

        _a = calculateOverlap(X, b.label);
        if (_a > 0) {
          c++;
          a += _a * importance_label;
        }
      }
    }
    /* calculate intersection with border */
    _a = calculateOverlap(X, [
      0,
      0,
      this.range_x.options.basis,
      this.range_y.options.basis,
    ]);
    a += ((X[2] - X[0]) * (X[3] - X[1]) - _a) * O.importance_border;
    return { intersect: a, count: c };
  }
}
/**
 * @member {Grid} Chart#grid - The grid element of the chart.
 *   Has class <code>.aux-grid</code>.
 */
defineChildWidget(Chart, 'grid', {
  create: Grid,
  show: true,
  no_resize: true,
  append: function () {
    this.svg.insertBefore(this.grid.element, this.svg.firstChild);
  },
  map_options: {
    grid_x: 'grid_x',
    grid_y: 'grid_y',
  },
  default_options: function () {
    return {
      range_x: this.range_x,
      range_y: this.range_y,
    };
  },
});
/**
 * @member {SVGText} Chart#_label - The label of the chart.
 *   Has class <code>.aux-label</code>.
 */
defineChildElement(Chart, 'label', {
  option: 'label',
  dependency: SymLabelChanged,
  display_check: function (v) {
    return typeof v === 'string' && v.length;
  },
  create: function () {
    return makeSVG('text', {
      class: 'aux-label',
      style: 'dominant-baseline: central;',
    });
  },
  append: function () {
    const svg = this.svg;
    svg.insertBefore(this._label, svg.firstChild);
  },
});