widgets/grid.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 { S } from '../dom_scheduler.js';
import { defineRange } from '../utils/define_range.js';
import { addClass, getStyle, empty } from '../utils/dom.js';
import { makeSVG } from '../utils/svg.js';
import { Widget } from './widget.js';

function drawLines(a, mode, last) {
  const labels = new Array(a.length);
  const coords = new Array(a.length);

  for (let i = 0; i < a.length; i++) {
    let obj = a[i];
    if (typeof obj === 'number') obj = { pos: obj };
    if (obj.label) {
      const label = makeSVG('text');
      label.textContent = obj.label;
      label.style['dominant-baseline'] = 'central';
      addClass(label, 'aux-gridlabel');
      addClass(label, mode ? 'aux-horizontal' : 'aux-vertical');
      if (obj['class']) addClass(label, obj['class']);

      this.element.appendChild(label);
      labels[i] = label;
    }
  }

  const w = this.range_x.options.basis;
  const h = this.range_y.options.basis;

  S.add(
    function () {
      /* FORCE_RELAYOUT */

      for (let i = 0; i < a.length; i++) {
        const obj = a[i];
        const label = labels[i];
        if (!label) continue;
        let bb;
        try {
          bb = label.getBBox();
        } catch (e) {
          // if we are hidden, this may throw
          // we should force redraw at some later point, but
          // its hard to do. the grid should really be deactivated
          // by an option.
          continue;
        }
        const tw = bb.width;
        const th = bb.height;
        const p = getStyle(label, 'padding').split(' ');
        if (p.length < 2) p[1] = p[2] = p[3] = p[0];
        if (p.length < 3) {
          p[2] = p[0];
          p[3] = p[1];
        }
        if (p.length < 4) p[3] = p[1];
        const pt = parseInt(p[0]) || 0;
        const pr = parseInt(p[1]) || 0;
        const pb = parseInt(p[2]) || 0;
        const pl = parseInt(p[3]) || 0;
        let x, y;
        if (mode) {
          y = Math.max(
            th / 2,
            Math.min(h - th / 2 - pt, this.range_y.valueToPixel(obj.pos))
          );
          if (y > last) continue;
          x = w - tw - pl;
          coords[i] = {
            x: x,
            y: y,
            m: tw + pl + pr,
            x_min: a[i].x_min,
            x_max: a[i].x_max,
          };
          last = y - th;
        } else {
          x = Math.max(
            pl,
            Math.min(w - tw - pl, this.range_x.valueToPixel(obj.pos) - tw / 2)
          );
          if (x < last) continue;
          y = h - th / 2 - pt;
          coords[i] = {
            x: x,
            y: y,
            m: th + pt + pb,
            y_min: a[i].y_min,
            y_max: a[i].y_max,
          };
          last = x + tw;
        }
      }

      S.add(
        function () {
          const O = this.options;
          const elements = this.element.querySelectorAll(
            '.aux-gridline.aux-' + (mode ? 'horizontal' : 'vertical')
          );
          for (let j = 0; j < elements.length; j++) {
            elements[j].parentElement.removeChild(elements[j]);
          }
          for (let j = 0; j < a.length; j++) {
            const label = labels[j];
            if (label) {
              const obj = coords[j];
              if (obj) {
                label.setAttribute('x', obj.x);
                label.setAttribute('y', obj.y);
              } else {
                if (label.parentElement == this.element)
                  this.element.removeChild(label);
              }
            }
          }

          for (let j = 0; j < a.length; j++) {
            const obj = a[j];
            const label = coords[j];
            let m, x_min, x_max, y_min, y_max, x, y;
            if (label) m = label.m;
            else m = 0;

            if (
              (mode && obj.pos === this.range_y.options.min) ||
              (mode && obj.pos === this.range_y.options.max) ||
              (!mode && obj.pos === this.range_x.options.min) ||
              (!mode && obj.pos === this.range_x.options.max)
            )
              continue;
            const line = makeSVG('path');
            addClass(line, 'aux-gridline');
            addClass(line, mode ? 'aux-horizontal' : 'aux-vertical');
            if (obj['class']) addClass(line, obj['class']);
            if (obj.color) line.setAttribute('style', 'stroke:' + obj.color);
            if (mode) {
              // line from left to right
              if (typeof obj.x_min === 'number')
                x_min = Math.max(0, this.range_x.valueToPixel(obj.x_min));
              else if (O.x_min !== false)
                x_min = Math.max(0, this.range_x.valueToPixel(O.x_min));
              else x_min = 0;
              if (typeof obj.x_max === 'number')
                x_max = Math.min(
                  this.range_x.options.basis - m,
                  this.range_x.valueToPixel(obj.x_max)
                );
              else if (O.x_max !== false)
                x_max = Math.min(
                  this.range_x.options.basis - m,
                  this.range_x.valueToPixel(O.x_max)
                );
              else x_max = this.range_x.options.basis - m;
              y = Math.round(this.range_y.valueToPixel(obj.pos));
              line.setAttribute(
                'd',
                'M' + x_min + ' ' + y + '.5 L' + x_max + ' ' + y + '.5'
              );
            } else {
              // line from top to bottom
              if (typeof obj.y_min === 'number')
                y_min = Math.max(0, this.range_y.valueToPixel(obj.y_min));
              else if (O.y_min !== false)
                y_min = Math.max(0, this.range_y.valueToPixel(O.y_min));
              else y_min = 0;
              if (typeof obj.y_max === 'number')
                y_max = Math.min(
                  this.range_y.options.basis - m,
                  this.range_y.valueToPixel(obj.y_max)
                );
              else if (O.y_max !== false)
                y_max = Math.min(
                  this.range_y.options.basis - m,
                  this.range_y.valueToPixel(O.y_max)
                );
              else y_max = this.range_y.options.basis - m;
              x = Math.round(this.range_x.valueToPixel(obj.pos));
              line.setAttribute(
                'd',
                'M' + x + '.5 ' + y_min + ' L' + x + '.5 ' + y_max
              );
            }
            this.element.appendChild(line);
          }
        }.bind(this),
        1
      );
    }.bind(this)
  );
}
/**
 * Grid creates a couple of lines and labels in a SVG
 * image on the x and y axis. It is used in e.g. {@link Graph} and
 * {@link FrequencyResponse} to draw markers and values. Grid needs a
 * parent SVG image do draw into. The base element of a Grid is a
 * SVG group containing all the labels and lines.
 *
 * @class Grid
 *
 * @param {Object} [options={ }] - An object containing initial options.
 *
 * @property {Array<Object|Number>} [options.grid_x=[]] - Array for vertical grid lines
 *   containing either numbers or objects with the members:
 * @property {Number} [options.grid_x.pos] - The value where to draw grid line and correspon ding 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 {String} [options.grid_x.y_min] - Start this line at this values position instead of 0.
 * @property {String} [options.grid_x.y_max] - End this line at this values position instead of maximum height.
 * @property {Array<Object|Number>} [options.grid_y=[]] - Array for horizontal grid lines
 *   containing either positions or objects with the members:
 * @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 {String} [options.grid_y.x_min] - Start this line at this values position instead of 0.
 * @property {String} [options.grid_y.x_max] - End this line at this values position instead of maximum width.
 * @property {Function|Object} [options.range_x={}] - A function returning
 *   a {@link Range} instance for vertical grid lines or an object
 *   containing options. for a new {@link Range}.
 * @property {Function|Object} [options.range_y={}] - A function returning
 *   a {@link Range} instance for horizontal grid lines or an object
 *   containing options. for a new {@link Range}.
 * @property {Number} [options.width=0] - Width of the grid.
 * @property {Number} [options.height=0] - Height of the grid.
 * @property {Number} [options.x_min=false] - Value to start horizontal
 *   lines at this position instead of 0.
 * @property {Number} [options.x_max=false] - Value to end horizontal
 *   lines at this position instead of maximum width.
 * @property {Number} [options.y_min=false] - Value to start vertical
 *   lines at this position instead of 0.
 * @property {Number} [options.y_max=false] - Value to end vertical
 *   lines at this position instead of maximum height.
 *
 *
 * @extends Widget
 */
export class Grid extends Widget {
  static get _options() {
    return Object.assign({}, Widget.getOptionTypes(), {
      grid_x: 'array',
      grid_y: 'array',
      range_x: 'object',
      range_y: 'object',
      width: 'number',
      height: 'number',
      x_min: 'boolean|number',
      x_max: 'boolean|number',
      y_min: 'boolean|number',
      y_max: 'boolean|number',
    });
  }

  static get options() {
    return {
      grid_x: [],
      grid_y: [],
      range_x: {},
      range_y: {},
      width: 0,
      height: 0,
      x_min: false,
      x_max: false,
      y_min: false,
      y_max: false,
    };
  }

  initialize(options) {
    if (!options.element) options.element = makeSVG('g');
    super.initialize(options);
    /**
     * @member {SVGGroup} Grid#element - The main SVG group containing all grid elements. Has class <code>.aux-grid</code>.
     */
    /**
     * @member {Range} Grid#range_x - The range for the x axis.
     */
    /**
     * @member {Range} Grid#range_y - The range for the y axis.
     */
    defineRange(this, this.options.range_x, 'range_x');
    defineRange(this, this.options.range_y, 'range_y');
    if (this.options.width) this.set('width', this.options.width);
    if (this.options.height) this.set('height', this.options.width);
  }

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

    super.draw(O, element);
  }

  redraw() {
    const I = this.invalid,
      O = this.options;
    if (I.validate('grid_x', 'grid_y', 'range_x', 'range_y')) {
      empty(this.element);

      drawLines.call(this, O.grid_x, false, 0);
      drawLines.call(this, O.grid_y, true, this.range_y.options.basis);
    }
    super.redraw();
  }

  // GETTER & SETTER
  set(key, value) {
    this.options[key] = value;
    switch (key) {
      case 'width':
        this.range_x.set('basis', value);
        break;
      case 'height':
        this.range_y.set('basis', value);
        break;
    }
    super.set(key, value);
  }
}