matrix/widgets/indicators.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 { defineChildWidget } from './../../child_widget.js';
import { addClass, removeClass } from './../../utils/dom.js';
import { scrollbarSize } from './../../utils/scrollbar_size.js';
import { FORMAT } from '../../utils/sprintf.js';
import { Subscriptions } from '../../utils/subscriptions.js';
import { subscribeDOMEvent } from '../../utils/events.js';

import { Button } from './../../widgets/button.js';
import { ConfirmButton } from './../../widgets/confirmbutton.js';
import { Container } from './../../widgets/container.js';
import { Indicator } from './indicator.js';
import { DragCapture } from '../../modules/dragcapture.js';
import { resizeArrayMod } from '../models.js';
import { defineRender, defineRecalculation } from '../../renderer.js';

scrollbarSize();

function onIndicatorClicked() {
  const indicators = this.parent;

  indicators.emit('indicatorClicked', this.source, this.sink);
}

const formatIndicatorTransform = FORMAT(
  'translateY(%.2fpx) translateX(%.2fpx)'
);

function getStartEvent(state) {
  if (state.findTouch) {
    return state.findTouch(state.start);
  } else {
    return state.start;
  }
}
function onDragStart(state, start, e) {
  return !this.get('_batch_open');
}

function onDragging(s, e) {
  const O = this.options;
  const state = this.drag.state();
  const px = state.vDistance();
  const x = px[0];
  const y = px[1];
  if (!O._batch) {
    const dist = Math.sqrt(x * x + y * y);
    if (dist > O.min_distance) {
      const start = getStartEvent(state);
      this.set('_batch', true);
      this.set('_x0', start.clientX);
      this.set('_y0', start.clientY);
      this.set('_xd', 0);
      this.set('_yd', 0);
    }
  } else {
    this.set('_xd', x);
    this.set('_yd', y);
  }
}

function onDragStop(state, e) {
  if (this.get('_batch')) onBatchStart.call(this, e);
  else onBatchEnd.call(this);
}

function onBatchStart() {
  const O = this.options;
  this.set(
    'show_buttons',
    O.has_select_diagonal || O.has_deselect_all || O.has_deselect_diagonal
  );
  this.set('show_cancel', true);
  this.set('show_select_diagonal', O.has_select_diagonal);
  this.set('show_deselect_diagonal', O.has_deselect_diagonal);
  this.set('show_deselect_all', O.has_deselect_all);
  this.set('_batch_open', true);
}

function onBatchEnd() {
  this.set('show_cancel', false);
  this.set('show_select_diagonal', false);
  this.set('show_deselect_diagonal', false);
  this.set('show_deselect_all', false);
  this.set('show_buttons', false);
  this.set('_batch', false);
  this.set('_batch_open', false);
}

function cancel() {
  //this.emit("cancel");
  onBatchEnd.call(this);
}

/**
 * Indicators is an area inside {@link Matrix} containing a matrix of
 *   {@link Indicator}s displaying and managing connections.
 *
 * @param {Object}[options={ }] - An object containing initial options.
 *
 * @property {Object} [options.indicator_class=Indicator] - the class to
 *   derive new {@link Indicator}s from. Has to be a subclass of
 *   {@link Indicator}.
 * @property {ConnectionView} options.connectionview - The
 *   {@link ConnectionView} data model.
 * @property {Boolean} [options.batch=true] - Disable batch connection
 *   rectangle.
 * @property {Number} [options.min_distance] - Minimum distance the user
 *   has to drag in order to display the batch rectangle.
 * @property {Number} [options.has_deselect_all] - Batch can deselect all
 *   connections.
 * @property {Number} [options.has_deselect_diagonal] - Batch can deselect
 *   diagonal connections.
 * @property {Number} [options.has_select_diagonal] - Batch can select
 *   diagonal connections.
 *
 * @extends Container
 *
 * @class Indicators
 */

export class Indicators extends Container {
  static get _options() {
    return {
      indicator_class: 'object',
      connectionview: 'object',
      batch: 'boolean',
      min_distance: 'number',
      has_select_diagonal: 'boolean',
      has_deselect_all: 'boolean',
      has_deselect_diagonal: 'boolean',
      _batch: 'boolean',
      _batch_open: 'boolean',
      _x0: 'number',
      _y0: 'number',
      _xd: 'number',
      _yd: 'number',
      _xinit: 'number',
      _yinit: 'number',
    };
  }

  static get options() {
    return {
      indicator_class: Indicator,
      min_distance: 40,
      batch: true,
      has_select_diagonal: true,
      has_deselect_all: true,
      has_deselect_diagonal: true,
      _batch: false,
      _batch_open: false,
      _x0: 0,
      _y0: 0,
      _xd: 0,
      _yd: 0,
      _xinit: 0,
      _yinit: 0,
    };
  }

  static get static_events() {
    return {
      set_batch: function (v) {
        this.drag.set('active', v);
      },
    };
  }

  static get renderers() {
    return [
      defineRecalculation(
        ['_x0', '_y0', '_xd', '_yd', '_xinit', '_yinit'],
        function (_x0, _y0, _xd, _yd, _xinit, _yinit) {
          this.set('_rect', this._calculateRectangle());
        }
      ),
      defineRender(['show_buttons', '_rect'], function (show_buttons, _rect) {
        const { _batch } = this;

        if (!_batch) return;

        const x = _rect.flip_x ? 'left' : 'right';
        const y = _rect.flip_y ? 'top' : 'bottom';
        removeClass(_batch, 'aux-top-left');
        removeClass(_batch, 'aux-bottom-left');
        removeClass(_batch, 'aux-top-right');
        removeClass(_batch, 'aux-bottom-right');
        addClass(_batch, 'aux-' + y + '-' + x);
      }),
      defineRender('_rect', function (_rect) {
        const { _batch } = this;

        if (!_batch) return;

        const style = _batch.style;

        style.left = _rect.x + 'px';
        style.top = _rect.y + 'px';
        style.width = _rect.width + 'px';
        style.height = _rect.height + 'px';
      }),
      defineRender(['_columns', '_rows', 'size'], function (
        _columns,
        _rows,
        size
      ) {
        this._scroller.style.width = _columns * size + 'px';
        this._scroller.style.height = _rows * size + 'px';
      }),
      defineRender(['connectionview', 'size'], function (connectionview, size) {
        this.connectionview_subs.unsubscribe();

        if (!connectionview) return;

        const sub = this.connectionview_subs;

        sub.add(
          connectionview.subscribeSize((rows, columns) => {
            this.set('_rows', rows);
            this.set('_columns', columns);
          })
        );

        const setIndicatorPosition = (indicator, index1, index2) => {
          indicator.element.style.transform = formatIndicatorTransform(
            index1 * size,
            index2 * size
          );
        };

        const createIndicator = (index1, index2) => {
          const indicator = this.createIndicator();

          indicator.on('click', onIndicatorClicked);
          setIndicatorPosition(indicator, index1, index2);

          this._scroller.appendChild(indicator.element);
          this.addChild(indicator);

          return indicator;
        };

        const removeIndicator = (indicator) => {
          indicator.element.remove();
          indicator.off('click', onIndicatorClicked);
          this.removeChild(indicator);
        };

        sub.add(
          connectionview.subscribeAmount((rows, columns) => {
            const createRow = (index1) => {
              const row = new Array(columns);

              for (let i = 0; i < columns; i++) {
                const index2 = connectionview.startIndex2 + i;
                row[index2 % columns] = createIndicator(index1, index2);
              }

              return row;
            };

            const destroyRow = (row) => {
              row.forEach(removeIndicator);
            };

            resizeArrayMod(
              this.entries,
              rows,
              connectionview.startIndex1,
              createRow,
              destroyRow
            );

            for (let i = 0; i < rows; i++) {
              const index1 = connectionview.startIndex1 + i;
              const row = this.entries[index1 % rows];
              resizeArrayMod(
                row,
                columns,
                connectionview.startIndex2,
                (index2) => createIndicator(index1, index2),
                removeIndicator
              );
            }
          })
        );

        sub.add(
          connectionview.subscribeElements(
            (index1, index2, connection, source, sink) => {
              const entries = this.entries;
              const row = entries[index1 % entries.length];
              const indicator = row[index2 % row.length];

              indicator.updateData(
                index1,
                index2,
                connection,
                source,
                sink,
                connectionview
              );
              setIndicatorPosition(indicator, index1, index2);
            }
          )
        );
      }),
    ];
  }

  initialize(options) {
    super.initialize(options);
    this.connectionview_subs = new Subscriptions();
    this.entries = [];

    this.drag = new DragCapture(this, {
      node: this.element,
      active: options.batch,
      onstartcapture: onDragStart.bind(this),
      onmovecapture: onDragging.bind(this),
      onstopcapture: onDragStop.bind(this),
    });

    this._dragging = false;
  }

  destroy() {
    super.destroy();
    this.connectionview_subs.unsubscribe();
  }

  createIndicator() {
    return new this.options.indicator_class();
  }

  draw(options, element) {
    super.draw(options, element);
    addClass(element, 'aux-indicators');
    this.addSubscriptions(
      subscribeDOMEvent(this.element, 'scroll', (ev) => {
        onBatchEnd.call(this);
        /**
         * Is fired on scrolling the area.
         *
         * @event Indicators#scrollChanged
         *
         * @param {Integer} scroll_top - The scroll position from top.
         * @param {Integer} scroll_left - The scroll position from left.
         */
        // jshint -W123
        const element = this.element;
        // jshint +W123
        this.emit('scrollChanged', element.scrollTop, element.scrollLeft);
      })
    );
  }

  resize() {
    super.resize();
    const bbox = this.element.getBoundingClientRect();
    this.update('_xinit', bbox.x);
    this.update('_yinit', bbox.y);
  }

  _calculateRectangle() {
    const { _x0, _y0, _xd, _yd, _xinit, _yinit } = this.options;
    const stop = this.element.scrollTop;
    const sleft = this.element.scrollLeft;
    let width, height, x, y;
    if (_xd < 0) {
      x = _x0 + _xd + sleft;
      width = -_xd;
    } else {
      x = _x0 + sleft;
      width = _xd;
    }
    if (_yd < 0) {
      y = _y0 + _yd + stop;
      height = -_yd;
    } else {
      y = _y0 + stop;
      height = _yd;
    }
    x -= _xinit;
    y -= _yinit;
    return {
      x: x,
      y: y,
      width: width,
      height: height,
      flip_x: _xd < 0,
      flip_y: _yd < 0,
    };
  }

  _calculateIndexRectangle(rectangle) {
    const { x, y, width, height } = rectangle;
    const size = this.options.size;
    return {
      startColumn: x / size,
      endColumn: (x + width) / size,
      startRow: y / size,
      endRow: (y + height) / size,
    };
  }

  /**
   * Scroll the indicators area to this vertical (top) position.
   *
   * @param {Integer} position - the position in pixels to scroll to.
   *
   * @method Indicators#scrollTopTo
   */
  scrollTopTo(position) {
    this.scrollTo({ top: position });
  }

  /**
   * Scroll the indicators area to this horizontal (left) position.
   *
   * @param {Integer} position - the position in pixels to scroll to.
   *
   * @method Indicators#scrollLeftTo
   */
  scrollLeftTo(position) {
    this.scrollTo({ left: position });
  }

  scrollTo(options) {
    this.element.scrollTo(options);
  }

  // Event handler for batch operation dialog.
  _onConnectDiagonalConfirmed() {
    const rectangle = this._calculateRectangle();
    const indexRectangle = this._calculateIndexRectangle(rectangle);
    this.emit('connectDiagonal', indexRectangle, rectangle);
    onBatchEnd.call(this);
  }

  _onDisconnectDiagonalConfirmed() {
    const rectangle = this._calculateRectangle();
    const indexRectangle = this._calculateIndexRectangle(rectangle);
    this.emit('disconnectDiagonal', indexRectangle, rectangle);
    onBatchEnd.call(this);
  }

  _onDisconnectAllConfirmed() {
    const rectangle = this._calculateRectangle();
    const indexRectangle = this._calculateIndexRectangle(rectangle);
    this.emit('disconnectAll', indexRectangle, rectangle);
    onBatchEnd.call(this);
  }
}

/**
 * @member {HTMLDiv} Indicators#_scroller - The container for hiding
 *   the scroll bar.
 *   Has class <code>.aux-scroller</code>.
 */
defineChildElement(Indicators, 'scroller', {
  show: true,
});

/**
 * @member {HTMLDiv} Indicators#_batch - The rectangle to indicate
 *   batch selection/deselection.
 *   Has class `.aux-batch`.
 */
defineChildElement(Indicators, 'batch', {
  show: false,
  option: '_batch',
});

/**
 * @member {Container} Indicators#buttons - The container holding
 *   the buttons for batch connection management.
 *   Has class <code>.aux-batchbuttons</code>.
 */
defineChildWidget(Indicators, 'buttons', {
  create: Container,
  default_options: {
    class: 'aux-batchbuttons',
  },
  append: function () {
    this._batch.appendChild(this.buttons.element);
  },
});
/**
 * @member {Button} Indicators#deselect_diagonal - The button for
 *   disconnecting diagonally.
 *   Has class <code>.aux-deselectdiagonal</code>.
 */
defineChildWidget(Indicators, 'deselect_diagonal', {
  create: ConfirmButton,
  default_options: {
    icon: 'matrixdeselectdiagonal',
    icon_confirm: 'questionmark',
    class: 'aux-deselectdiagonal',
  },
  static_events: {
    mousedown: function (ev) {
      ev.stopPropagation();
      return false;
    },
    confirmed: function () {
      this.parent._onDisconnectDiagonalConfirmed();
      return false;
    },
  },
  append: function () {
    this.buttons.element.appendChild(this.deselect_diagonal.element);
  },
});
/**
 * @member {Button} Indicators#deselect_all - The button for
 *   disconnecting all.
 *   Has class <code>.aux-deselectall</code>.
 */
defineChildWidget(Indicators, 'deselect_all', {
  create: ConfirmButton,
  default_options: {
    icon: 'matrixdeselectall',
    icon_confirm: 'questionmark',
    class: 'aux-deselectall',
  },
  static_events: {
    mousedown: function (ev) {
      ev.stopPropagation();
      return false;
    },
    confirmed: function () {
      this.parent._onDisconnectAllConfirmed();
      return false;
    },
  },
  append: function () {
    this.buttons.element.appendChild(this.deselect_all.element);
  },
});
/**
 * @member {Button} Indicators#select_diagonal - The button for
 *   connecting diagonally.
 *   Has class <code>.aux-selectdiagonal</code>.
 */
defineChildWidget(Indicators, 'select_diagonal', {
  create: ConfirmButton,
  default_options: {
    icon: 'matrixselectdiagonal',
    icon_confirm: 'questionmark',
    class: 'aux-selectdiagonal',
  },
  static_events: {
    mousedown: function (ev) {
      ev.stopPropagation();
      return false;
    },
    confirmed: function () {
      this.parent._onConnectDiagonalConfirmed();
      return false;
    },
  },
  append: function () {
    this.buttons.element.appendChild(this.select_diagonal.element);
  },
});
/**
 * @member {Button} Indicators#cancel - The button for
 *   hiding the rectangle.
 *   Has class <code>.aux-cancel</code>.
 */
defineChildWidget(Indicators, 'cancel', {
  create: Button,
  default_options: {
    icon: 'close',
    class: 'aux-cancel',
  },
  static_events: {
    mousedown: function (ev) {
      ev.stopPropagation();
      return false;
    },
    click: function () {
      cancel.call(this.parent);
    },
  },
  append: function () {
    this.buttons.element.appendChild(this.cancel.element);
  },
});