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 { defineRange } from '../utils/define_range.js';
import { addClass, getStyle, empty } from '../utils/dom.js';
import { makeSVG } from '../utils/svg.js';
import { Widget, SymResize } from './widget.js';
import { defineRender, deferRender, deferMeasure } from '../renderer.js';

function getPadding(element) {
  const tmp = getStyle(element, 'padding').split(' ');

  /*eslint no-fallthrough: "error"*/
  switch (tmp.length) {
    case 1:
      tmp[1] = tmp[0];
    // falls through
    case 2:
      tmp[2] = tmp[0];
    // falls through
    case 3:
      tmp[3] = tmp[1];
  }

  return tmp.map((entry) => parseInt(entry) || 0);
}

function createLabels(grid, element, horizontal) {
  return grid.map((obj) => {
    if (typeof obj === 'number') return null;

    const label = obj.label;

    if (typeof label !== 'string') return null;

    const labelNode = makeSVG('text');
    labelNode.textContent = label;
    labelNode.style['dominant-baseline'] = 'central';
    addClass(labelNode, 'aux-gridlabel');
    addClass(labelNode, horizontal ? 'aux-horizontal' : 'aux-vertical');

    const cl = obj.class;
    if (cl) addClass(labelNode, cl);

    element.appendChild(labelNode);
    return labelNode;
  });
}

function positionLabels(labels, coords) {
  labels.forEach((label, i) => {
    if (!label) return;
    const position = coords[i];

    if (position) {
      label.setAttribute('x', position.x);
      label.setAttribute('y', position.y);
    } else {
      label.remove();
    }
  });
}

function measureCoords(
  grid,
  labels,
  last,
  basis_x,
  basis_y,
  range,
  horizontal
) {
  return grid.map((obj, i) => {
    const label = labels[i];
    if (!label) return;

    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.
      return;
    }

    const { width, height } = bb;
    const [pt, pr, pb, pl] = getPadding(label);
    let x, y, m;

    if (horizontal) {
      y = Math.max(
        height / 2,
        Math.min(basis_y - height / 2 - pt, range.valueToPixel(obj.pos))
      );
      if (y > last) return;
      x = basis_x - width - (width ? pl : 0);
      if (!i) last = y - height;
      m = width + (width ? pl + pr : 0);
    } else {
      x = Math.max(
        pl,
        Math.min(basis_x - width - pl, range.valueToPixel(obj.pos) - width / 2)
      );
      if (x < last) return;
      y = basis_y - height / 2 - (height ? pt : 0);
      last = x + width;
      m = height + (height ? pt + pb : 0);
    }

    return {
      x,
      y,
      m,
      x_min: obj.x_min,
      x_max: obj.x_max,
    };
  });
}

function drawLines(
  grid,
  coords,
  last,
  range,
  opprange,
  basis,
  oppbasis,
  min,
  max,
  path,
  element,
  cls,
  dir
) {
  for (let j = 0; j < grid.length; j++) {
    const obj = grid[j];
    const label = coords[j];
    let margin;

    if (label) margin = label.m;
    else margin = 0;

    if (obj.pos === opprange.options.min || obj.pos === opprange.options.max)
      continue;

    let line = makeSVG('path');
    addClass(line, 'aux-gridline');
    addClass(line, cls);
    if (obj['class']) addClass(line, obj['class']);
    if (obj.color) line.setAttribute('style', 'stroke:' + obj.color);

    const _min = obj[dir + '_min'];
    const _max = obj[dir + '_max'];

    const pos = Math.round(opprange.valueToPixel(obj.pos));

    if (pos >= oppbasis - last) {
      line = null;
    } else {
      let start, end;
      if (typeof _min === 'number')
        start = Math.max(0, range.valueToPixel(_min));
      else if (min !== false) start = Math.max(0, range.valueToPixel(min));
      else start = 0;
      if (typeof _max === 'number')
        end = Math.min(basis - margin, range.valueToPixel(_max));
      else if (max !== false)
        end = Math.min(basis - margin, range.valueToPixel(max));
      else end = basis - margin;
      line.setAttribute('d', path(pos, start, end));
    }

    if (line) element.appendChild(line);
  }
}

function drawGrid() {
  const {
    grid_x,
    grid_y,
    range_x,
    range_y,
    x_min,
    x_max,
    y_min,
    y_max,
  } = this.options;

  const basis_x = range_x.options.basis;
  const basis_y = range_y.options.basis;

  if (!basis_x || !basis_y) return;

  empty(this.element);

  const labels_x = createLabels(grid_x, this.element, false);
  const labels_y = createLabels(grid_y, this.element, true);

  return deferMeasure(() => {
    const coords_x = measureCoords(
      grid_x,
      labels_x,
      0,
      basis_x,
      basis_y,
      range_x,
      false
    );
    const coords_y = measureCoords(
      grid_y,
      labels_y,
      basis_y,
      basis_x,
      basis_y,
      range_y,
      true
    );

    return deferRender(() => {
      positionLabels(labels_x, coords_x);
      positionLabels(labels_y, coords_y);

      const margins_x = coords_x.map((v) => (v ? v.m : 0));
      const margins_y = coords_y.map((v) => (v ? v.m : 0));

      const last_x = Math.max(...margins_y);
      const last_y = Math.max(...margins_x);

      drawLines(
        grid_x,
        coords_x,
        last_x,
        range_y,
        range_x,
        basis_y,
        basis_x,
        y_min,
        y_max,
        (pos, min, max) => 'M' + pos + '.5 ' + min + ' L' + pos + '.5 ' + max,
        this.element,
        'aux-vertical',
        'y'
      );
      drawLines(
        grid_y,
        coords_y,
        last_y,
        range_x,
        range_y,
        basis_x,
        basis_y,
        x_min,
        x_max,
        (pos, min, max) =>
          'M' + min + ' ' + pos + '.5 L' + max + ' ' + pos + '.5',
        this.element,
        'aux-horizontal',
        'x'
      );
    });
  });
}
/**
 * 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 {
      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,
    };
  }

  static get renderers() {
    return [
      defineRender(
        [
          'range_x',
          'range_y',
          'grid_x',
          'grid_y',
          'x_min',
          'x_max',
          'y_min',
          'y_max',
          SymResize,
        ],
        function (
          range_x,
          range_y,
          grid_x,
          grid_y,
          x_min,
          x_max,
          y_min,
          y_max
        ) {
          return drawGrid.call(this);
        }
      ),
    ];
  }

  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);
  }

  // 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);
  }
}