lib/shared/Configurator.js

Summary

Maintainability
F
5 days
Test Coverage
var util = require('../util');

var ColorPicker = require('./ColorPicker').default;

/**
 * The way this works is for all properties of this.possible options, you can supply the property name in any form to list the options.
 * Boolean options are recognised as Boolean
 * Number options should be written as array: [default value, min value, max value, stepsize]
 * Colors should be written as array: ['color', '#ffffff']
 * Strings with should be written as array: [option1, option2, option3, ..]
 *
 * The options are matched with their counterparts in each of the modules and the values used in the configuration are
 */
class Configurator {
  /**
   * @param {Object} parentModule        | the location where parentModule.setOptions() can be called
   * @param {Object} defaultContainer    | the default container of the module
   * @param {Object} configureOptions    | the fully configured and predefined options set found in allOptions.js
   * @param {number} pixelRatio          | canvas pixel ratio
   */
  constructor(parentModule, defaultContainer, configureOptions, pixelRatio = 1) {
    this.parent = parentModule;
    this.changedOptions = [];
    this.container = defaultContainer;
    this.allowCreation = false;

    this.options = {};
    this.initialized = false;
    this.popupCounter = 0;
    this.defaultOptions = {
      enabled: false,
      filter: true,
      container: undefined,
      showButton: true
    };
    util.extend(this.options, this.defaultOptions);

    this.configureOptions = configureOptions;
    this.moduleOptions = {};
    this.domElements = [];
    this.popupDiv = {};
    this.popupLimit = 5;
    this.popupHistory = {};
    this.colorPicker = new ColorPicker(pixelRatio);
    this.wrapper = undefined;
  }


  /**
   * refresh all options.
   * Because all modules parse their options by themselves, we just use their options. We copy them here.
   *
   * @param {Object} options
   */
  setOptions(options) {
    if (options !== undefined) {
      // reset the popup history because the indices may have been changed.
      this.popupHistory = {};
      this._removePopup();

      let enabled = true;
      if (typeof options === 'string') {
        this.options.filter = options;
      }
      else if (options instanceof Array) {
        this.options.filter = options.join();
      }
      else if (typeof options === 'object') {
        if (options.container !== undefined) {
          this.options.container = options.container;
        }
        if (options.filter !== undefined) {
          this.options.filter = options.filter;
        }
        if (options.showButton !== undefined) {
          this.options.showButton = options.showButton;
        }
        if (options.enabled !== undefined) {
          enabled = options.enabled;
        }
      }
      else if (typeof options === 'boolean') {
        this.options.filter = true;
        enabled = options;
      }
      else if (typeof options === 'function') {
        this.options.filter = options;
        enabled = true;
      }
      if (this.options.filter === false) {
        enabled = false;
      }

      this.options.enabled = enabled;
    }
    this._clean();
  }

  /**
   *
   * @param {Object} moduleOptions
   */
  setModuleOptions(moduleOptions) {
    this.moduleOptions = moduleOptions;
    if (this.options.enabled === true) {
      this._clean();
      if (this.options.container !== undefined) {
        this.container = this.options.container;
      }
      this._create();
    }
  }

  /**
   * Create all DOM elements
   * @private
   */
  _create() {
    this._clean();
    this.changedOptions = [];

    let filter = this.options.filter;
    let counter = 0;
    let show = false;
    for (let option in this.configureOptions) {
      if (this.configureOptions.hasOwnProperty(option)) {
        this.allowCreation = false;
        show = false;
        if (typeof filter === 'function') {
          show = filter(option,[]);
          show = show || this._handleObject(this.configureOptions[option], [option], true);
        }
        else if (filter === true || filter.indexOf(option) !== -1) {
          show = true;
        }

        if (show !== false) {
          this.allowCreation = true;

          // linebreak between categories
          if (counter > 0) {
            this._makeItem([]);
          }
          // a header for the category
          this._makeHeader(option);

          // get the sub options
          this._handleObject(this.configureOptions[option], [option]);
        }
        counter++;
      }
    }

    if (this.options.showButton === true) {
      let generateButton = document.createElement('div');
      generateButton.className = 'vis-configuration vis-config-button';
      generateButton.innerHTML = 'generate options';
      generateButton.onclick =     () => {this._printOptions();};
      generateButton.onmouseover = () => {generateButton.className = 'vis-configuration vis-config-button hover';};
      generateButton.onmouseout =  () => {generateButton.className = 'vis-configuration vis-config-button';};

      this.optionsContainer = document.createElement('div');
      this.optionsContainer.className = 'vis-configuration vis-config-option-container';

      this.domElements.push(this.optionsContainer);
      this.domElements.push(generateButton);
    }

    this._push();
    //~ this.colorPicker.insertTo(this.container);
  }


  /**
   * draw all DOM elements on the screen
   * @private
   */
  _push() {
    this.wrapper = document.createElement('div');
    this.wrapper.className = 'vis-configuration-wrapper';
    this.container.appendChild(this.wrapper);
    for (var i = 0; i < this.domElements.length; i++) {
      this.wrapper.appendChild(this.domElements[i]);
    }

    this._showPopupIfNeeded()
  }


  /**
   * delete all DOM elements
   * @private
   */
  _clean() {
    for (var i = 0; i < this.domElements.length; i++) {
      this.wrapper.removeChild(this.domElements[i]);
    }

    if (this.wrapper !== undefined) {
      this.container.removeChild(this.wrapper);
      this.wrapper = undefined;
    }
    this.domElements = [];

    this._removePopup();
  }


  /**
   * get the value from the actualOptions if it exists
   * @param {array} path    | where to look for the actual option
   * @returns {*}
   * @private
   */
  _getValue(path) {
    let base = this.moduleOptions;
    for (let i = 0; i < path.length; i++) {
      if (base[path[i]] !== undefined) {
        base = base[path[i]];
      }
      else {
        base = undefined;
        break;
      }
    }
    return base;
  }


  /**
   * all option elements are wrapped in an item
   * @param {Array} path    | where to look for the actual option
   * @param {Array.<Element>} domElements
   * @returns {number}
   * @private
   */
  _makeItem(path, ...domElements) {
    if (this.allowCreation === true) {
      let item = document.createElement('div');
      item.className = 'vis-configuration vis-config-item vis-config-s' + path.length;
      domElements.forEach((element) => {
        item.appendChild(element);
      });
      this.domElements.push(item);
      return this.domElements.length;
    }
    return 0;
  }


  /**
   * header for major subjects
   * @param {string} name
   * @private
   */
  _makeHeader(name) {
    let div = document.createElement('div');
    div.className = 'vis-configuration vis-config-header';
    div.innerHTML = name;
    this._makeItem([],div);
  }


  /**
   * make a label, if it is an object label, it gets different styling.
   * @param {string} name
   * @param {array} path    | where to look for the actual option
   * @param {string} objectLabel
   * @returns {HTMLElement}
   * @private
   */
  _makeLabel(name, path, objectLabel = false) {
    let div = document.createElement('div');
    div.className = 'vis-configuration vis-config-label vis-config-s' + path.length;
    if (objectLabel === true) {
      div.innerHTML = '<i><b>' + name + ':</b></i>';
    }
    else {
      div.innerHTML = name + ':';
    }
    return div;
  }


  /**
   * make a dropdown list for multiple possible string optoins
   * @param {Array.<number>} arr
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _makeDropdown(arr, value, path) {
    let select = document.createElement('select');
    select.className = 'vis-configuration vis-config-select';
    let selectedValue = 0;
    if (value !== undefined) {
      if (arr.indexOf(value) !== -1) {
        selectedValue = arr.indexOf(value);
      }
    }

    for (let i = 0; i < arr.length; i++) {
      let option = document.createElement('option');
      option.value = arr[i];
      if (i === selectedValue) {
        option.selected = 'selected';
      }
      option.innerHTML = arr[i];
      select.appendChild(option);
    }

    let me = this;
    select.onchange = function () {me._update(this.value, path);};

    let label = this._makeLabel(path[path.length-1], path);
    this._makeItem(path, label, select);
  }


  /**
   * make a range object for numeric options
   * @param {Array.<number>} arr
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _makeRange(arr, value, path) {
    let defaultValue = arr[0];
    let min = arr[1];
    let max = arr[2];
    let step = arr[3];
    let range = document.createElement('input');
    range.className = 'vis-configuration vis-config-range';
    try {
      range.type = 'range'; // not supported on IE9
      range.min = min;
      range.max = max;
    }
    // TODO: Add some error handling and remove this lint exception
    catch (err) {}  // eslint-disable-line no-empty
    range.step = step;

    // set up the popup settings in case they are needed.
    let popupString = '';
    let popupValue = 0;

    if (value !== undefined) {
      let factor = 1.20;
      if (value < 0 && value * factor < min) {
        range.min = Math.ceil(value * factor);
        popupValue = range.min;
        popupString = 'range increased';
      }
      else if (value / factor < min) {
        range.min = Math.ceil(value / factor);
        popupValue = range.min;
        popupString = 'range increased';
      }
      if (value * factor > max && max !== 1) {
        range.max = Math.ceil(value * factor);
        popupValue = range.max;
        popupString = 'range increased';
      }
      range.value = value;
    }
    else {
      range.value = defaultValue;
    }

    let input = document.createElement('input');
    input.className = 'vis-configuration vis-config-rangeinput';
    input.value = range.value;

    var me = this;
    range.onchange = function () {input.value = this.value; me._update(Number(this.value), path);};
    range.oninput  = function () {input.value = this.value; };

    let label = this._makeLabel(path[path.length-1], path);
    let itemIndex = this._makeItem(path, label, range, input);

    // if a popup is needed AND it has not been shown for this value, show it.
    if (popupString !== '' && this.popupHistory[itemIndex] !== popupValue) {
      this.popupHistory[itemIndex] = popupValue;
      this._setupPopup(popupString, itemIndex);
    }

  }


  /**
   * prepare the popup
   * @param {string} string
   * @param {number} index
   * @private
   */
  _setupPopup(string, index) {
    if (this.initialized === true && this.allowCreation === true && this.popupCounter < this.popupLimit) {
      let div = document.createElement("div");
      div.id = "vis-configuration-popup";
      div.className = "vis-configuration-popup";
      div.innerHTML = string;
      div.onclick = () => {this._removePopup()};
      this.popupCounter += 1;
      this.popupDiv = {html:div, index:index};
    }
  }


  /**
   * remove the popup from the dom
   * @private
   */
  _removePopup() {
    if (this.popupDiv.html !== undefined) {
      this.popupDiv.html.parentNode.removeChild(this.popupDiv.html);
      clearTimeout(this.popupDiv.hideTimeout);
      clearTimeout(this.popupDiv.deleteTimeout);
      this.popupDiv = {};
    }
  }


  /**
   * Show the popup if it is needed.
   * @private
   */
  _showPopupIfNeeded() {
    if (this.popupDiv.html !== undefined) {
      let correspondingElement = this.domElements[this.popupDiv.index];
      let rect = correspondingElement.getBoundingClientRect();
      this.popupDiv.html.style.left = rect.left + "px";
      this.popupDiv.html.style.top = rect.top - 30 + "px"; // 30 is the height;
      document.body.appendChild(this.popupDiv.html)
      this.popupDiv.hideTimeout = setTimeout(() => {
        this.popupDiv.html.style.opacity = 0;
      },1500);
      this.popupDiv.deleteTimeout = setTimeout(() => {
        this._removePopup();
      },1800)
    }
  }

  /**
   * make a checkbox for boolean options.
   * @param {number} defaultValue
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _makeCheckbox(defaultValue, value, path) {
    var checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    checkbox.className = 'vis-configuration vis-config-checkbox';
    checkbox.checked = defaultValue;
    if (value !== undefined) {
      checkbox.checked = value;
      if (value !== defaultValue) {
        if (typeof defaultValue === 'object') {
          if (value !== defaultValue.enabled) {
            this.changedOptions.push({path:path, value:value});
          }
        }
        else {
          this.changedOptions.push({path:path, value:value});
        }
      }
    }

    let me = this;
    checkbox.onchange = function() {me._update(this.checked, path)};

    let label = this._makeLabel(path[path.length-1], path);
    this._makeItem(path, label, checkbox);
  }

  /**
   * make a text input field for string options.
   * @param {number} defaultValue
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _makeTextInput(defaultValue, value, path) {
    var checkbox = document.createElement('input');
    checkbox.type = 'text';
    checkbox.className = 'vis-configuration vis-config-text';
    checkbox.value = value;
    if (value !== defaultValue) {
      this.changedOptions.push({path:path, value:value});
    }

    let me = this;
    checkbox.onchange = function() {me._update(this.value, path)};

    let label = this._makeLabel(path[path.length-1], path);
    this._makeItem(path, label, checkbox);
  }


  /**
   * make a color field with a color picker for color fields
   * @param {Array.<number>} arr
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _makeColorField(arr, value, path) {
    let defaultColor = arr[1];
    let div = document.createElement('div');
    value = value === undefined ? defaultColor : value;

    if (value !== 'none') {
      div.className = 'vis-configuration vis-config-colorBlock';
      div.style.backgroundColor = value;
    }
    else {
      div.className = 'vis-configuration vis-config-colorBlock none';
    }

    value = value === undefined ? defaultColor : value;
    div.onclick = () => {
      this._showColorPicker(value,div,path);
    };

    let label = this._makeLabel(path[path.length-1], path);
    this._makeItem(path,label, div);
  }


  /**
   * used by the color buttons to call the color picker.
   * @param {number} value
   * @param {HTMLElement} div
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _showColorPicker(value, div, path) {
    // clear the callback from this div
    div.onclick = function() {};

    this.colorPicker.insertTo(div);
    this.colorPicker.show();

    this.colorPicker.setColor(value);
    this.colorPicker.setUpdateCallback((color) => {
      let colorString = 'rgba(' + color.r + ',' + color.g + ',' + color.b + ',' + color.a + ')';
      div.style.backgroundColor = colorString;
      this._update(colorString,path);
    });

    // on close of the colorpicker, restore the callback.
    this.colorPicker.setCloseCallback(() => {
      div.onclick = () => {
        this._showColorPicker(value,div,path);
      };
    });
  }


  /**
   * parse an object and draw the correct items
   * @param {Object} obj
   * @param {array} [path=[]]    | where to look for the actual option
   * @param {boolean} [checkOnly=false]
   * @returns {boolean}
   * @private
   */
  _handleObject(obj, path = [], checkOnly = false) {
    let show = false;
    let filter = this.options.filter;
    let visibleInSet = false;
    for (let subObj in obj) {
      if (obj.hasOwnProperty(subObj)) {
        show = true;
        let item = obj[subObj];
        let newPath = util.copyAndExtendArray(path, subObj);
        if (typeof filter === 'function') {
          show = filter(subObj,path);

          // if needed we must go deeper into the object.
          if (show === false) {
            if (!(item instanceof Array) && typeof item !== 'string' && typeof item !== 'boolean' && item instanceof Object) {
              this.allowCreation = false;
              show = this._handleObject(item, newPath, true);
              this.allowCreation = checkOnly === false;
            }
          }
        }

        if (show !== false) {
          visibleInSet = true;
          let value = this._getValue(newPath);

          if (item instanceof Array) {
            this._handleArray(item, value, newPath);
          }
          else if (typeof item === 'string') {
            this._makeTextInput(item, value, newPath);
          }
          else if (typeof item === 'boolean') {
            this._makeCheckbox(item, value, newPath);
          }
          else if (item instanceof Object) {
            // collapse the physics options that are not enabled
            let draw = true;
            if (path.indexOf('physics') !== -1) {
              if (this.moduleOptions.physics.solver !== subObj) {
                draw = false;
              }
            }

            if (draw === true) {
              // initially collapse options with an disabled enabled option.
              if (item.enabled !== undefined) {
                let enabledPath = util.copyAndExtendArray(newPath, 'enabled');
                let enabledValue = this._getValue(enabledPath);
                if (enabledValue === true) {
                  let label = this._makeLabel(subObj, newPath, true);
                  this._makeItem(newPath, label);
                  visibleInSet = this._handleObject(item, newPath) || visibleInSet;
                }
                else {
                  this._makeCheckbox(item, enabledValue, newPath);
                }
              }
              else {
                let label = this._makeLabel(subObj, newPath, true);
                this._makeItem(newPath, label);
                visibleInSet = this._handleObject(item, newPath) || visibleInSet;
              }
            }
          }
          else {
            console.error('dont know how to handle', item, subObj, newPath);
          }
        }
      }
    }
    return visibleInSet;
  }


  /**
   * handle the array type of option
   * @param {Array.<number>} arr
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _handleArray(arr, value, path) {
    if (typeof arr[0] === 'string' && arr[0] === 'color') {
      this._makeColorField(arr, value, path);
      if (arr[1] !== value) {this.changedOptions.push({path:path, value:value});}
    }
    else if (typeof arr[0] === 'string') {
      this._makeDropdown(arr, value, path);
      if (arr[0] !== value) {this.changedOptions.push({path:path, value:value});}
    }
    else if (typeof arr[0] === 'number') {
      this._makeRange(arr, value, path);
      if (arr[0] !== value) {this.changedOptions.push({path:path, value:Number(value)});}
    }
  }



  /**
   * called to update the network with the new settings.
   * @param {number} value
   * @param {array} path    | where to look for the actual option
   * @private
   */
  _update(value, path) {
    let options = this._constructOptions(value,path);

    if (this.parent.body && this.parent.body.emitter && this.parent.body.emitter.emit) {
      this.parent.body.emitter.emit("configChange", options);
    }
    this.initialized = true;
    this.parent.setOptions(options);
  }


  /**
   *
   * @param {string|Boolean} value
   * @param {Array.<string>} path
   * @param {{}} optionsObj
   * @returns {{}}
   * @private
   */
  _constructOptions(value, path, optionsObj = {}) {
    let pointer = optionsObj;

    // when dropdown boxes can be string or boolean, we typecast it into correct types
    value = value === 'true'  ? true  : value;
    value = value === 'false' ? false : value;

    for (let i = 0; i < path.length; i++) {
      if (path[i] !== 'global') {
        if (pointer[path[i]] === undefined) {
          pointer[path[i]] = {};
        }
        if (i !== path.length - 1) {
          pointer = pointer[path[i]];
        }
        else {
          pointer[path[i]] = value;
        }
      }
    }
    return optionsObj;
  }

  /**
   * @private
   */
  _printOptions() {
    let options = this.getOptions();
    this.optionsContainer.innerHTML = '<pre>var options = ' + JSON.stringify(options, null, 2) + '</pre>';
  }

  /**
   *
   * @returns {{}} options
   */
  getOptions() {
    let options = {};
    for (var i = 0; i < this.changedOptions.length; i++) {
      this._constructOptions(this.changedOptions[i].value, this.changedOptions[i].path, options)
    }
    return options;
  }
}


export default Configurator;