Gapminder/vizabi

View on GitHub
src/components/buttonlist/buttonlist.js

Summary

Maintainability
F
1 wk
Test Coverage
import * as utils from "base/utils";
import Component from "base/component";
import * as iconset from "base/iconset";

/*!
 * VIZABI BUTTONLIST
 * Reusable buttonlist component
 */

//default existing buttons
const class_active = "vzb-active";
const class_hidden = "vzb-hidden";
const class_active_locked = "vzb-active-locked";
const class_hide_btn = "vzb-dialog-side-btn";
const class_unavailable = "vzb-unavailable";
const class_vzb_fullscreen = "vzb-force-fullscreen";
const class_container_fullscreen = "vzb-container-fullscreen";

const ButtonList = Component.extend({

  /**
   * Initializes the buttonlist
   * @param config component configuration
   * @param context component context (parent)
   */
  init(config, context) {

    //set properties
    const _this = this;
    this.name = this.name || "gapminder-buttonlist";
    //    this.template = '<div class="vzb-buttonlist"></div>';

    this.model_expects = [{
      name: "state",
      type: "model"
    }, {
      name: "ui",
      type: "ui"
    }, {
      name: "locale",
      type: "locale"
    }];

    this._available_buttons = {
      "find": {
        title: "buttons/find",
        icon: "search",
        required: false
      },
      "show": {
        title: "buttons/show",
        icon: "asterisk",
        required: false
      },
      "moreoptions": {
        title: "buttons/more_options",
        icon: "gear",
        required: true
      },
      "colors": {
        title: "buttons/colors",
        icon: "paintbrush",
        required: false
      },
      "mapcolors": {
        title: "buttons/mapcolors",
        icon: "paintbrush",
        required: false
      },
      "size": {
        title: "buttons/size",
        icon: "circle",
        required: false
      },
      "zoom": {
        title: "buttons/zoom",
        icon: "cursorPlus",
        required: false
      },
      "fullscreen": {
        title: "buttons/expand",
        icon: "expand",
        func: this.toggleFullScreen.bind(this),
        required: true
      },
      "trails": {
        title: "buttons/trails",
        icon: "trails",
        func: this.toggleBubbleTrails.bind(this),
        required: false,
        statebind: "ui.chart.trails",
        statebindfunc: this.setBubbleTrails.bind(this)
      },
      "forecast": {
        title: "buttons/forecast",
        icon: "forecast",
        func: this.toggleTimeForecast.bind(this),
        required: false,
        statebind: "state.time.showForecast",
        statebindfunc: this.setTimeForecast.bind(this)
      },
      "lock": {
        title: "buttons/lock",
        icon: "lock",
        func: this.toggleBubbleLock.bind(this),
        required: false,
        statebind: "ui.chart.lockNonSelected",
        statebindfunc: this.setBubbleLock.bind(this)
      },
      "inpercent": {
        title: "buttons/inpercent",
        icon: "percent",
        func: this.toggleInpercent.bind(this),
        required: false,
        statebind: "ui.chart.inpercent",
        statebindfunc: this.setInpercent.bind(this)
      },
      "presentation": {
        title: "buttons/presentation",
        icon: "presentation",
        func: this.togglePresentationMode.bind(this),
        required: false,
        statebind: "ui.presentation",
        statebindfunc: this.setPresentationMode.bind(this)
      },
      "sidebarcollapse": {
        title: "buttons/sidebar_collapse",
        icon: "angleDoubleLeft",
        func: this.toggleSidebarCollapse.bind(this),
        required: true,
        statebind: "ui.sidebarCollapse",
        statebindfunc: this.setSidebarCollapse.bind(this),
        ignoreSize: true
      },
      "about": {
        title: "buttons/about",
        icon: "about",
        required: false
      },
      "axes": {
        title: "buttons/axes",
        icon: "axes",
        required: false
      },
      "axesmc": {
        title: "buttons/axesmc",
        icon: "axes",
        required: false
      },
      "stack": {
        title: "buttons/stack",
        icon: "stack",
        required: false
      },
      "side": {
        title: "buttons/side",
        icon: "side",
        required: false
      },
      "_default": {
        title: "Button",
        icon: "asterisk",
        required: false
      }
    };

    this._active_comp = false;

    this.model_binds = {
      "change:state.marker.select": function(evt, path) {
        if (!_this._readyOnce) return;
        if (path.indexOf("select.labelOffset") !== -1) return;

        _this.setBubbleTrails();
        _this.setBubbleLock();
        _this._toggleButtons();


        //scroll button list to end if bottons appeared or disappeared
        // if(_this.entitiesSelected_1 !== (_this.model.state.marker.select.length > 0)) {
        //   _this.scrollToEnd();
        // }
        // _this.entitiesSelected_1 = _this.model.state.marker.select.length > 0;
      },
      "change:ui.chart": function(evt, path) {
        if (!_this._readyOnce) return;

        if (path.indexOf("lockActive") > -1 || path.indexOf("lockUnavailable") > -1) {
          _this.setBubbleLock();
        }
      }
    };

    // config.ui is same as this.model.ui here but this.model.ui is not yet available because constructor hasn't been called.
    // can't call constructor earlier because this.model_binds needs to be complete before calling constructor
    config.ui.buttons.forEach(buttonId => {
      const button = _this._available_buttons[buttonId];
      if (button && button.statebind) {
        _this.model_binds["change:" + button.statebind] = function(evt) {
          if (!_this._readyOnce) return;
          button.statebindfunc(buttonId, evt.source.value);
        };
      }
    });

    // builds model
    this._super(config, context);

    this.validatePopupButtons(this.model.ui.buttons, this.model.ui.dialogs);

  },

  readyOnce() {
    const _this = this;

    this.element = d3.select(this.placeholder);
    this.element.selectAll("div").remove();

    this.root.findChildByName("gapminder-dialogs").on("close", (evt, params) => {
      _this.setButtonActive(params.id, false);
    });

    const button_expand = (this.model.ui.dialogs || {}).sidebar || [];

    // // if button_expand has been passed in with boolean param or array must check and covert to array
    // if (button_expand){
    //   this.model.ui.dialogs.sidebar = (button_expand === true) ? this.model.ui.buttons : button_expand;
    // }

    // if (button_expand && button_expand.length !== 0) {
    //     d3.select(this.root.element).classed("vzb-dialog-expand-true", true);
    // }

    const button_list = [].concat(this.model.ui.buttons);

    // (button_expand||[]).forEach(function(button) {
    //   if (button_list.indexOf(button) === -1) {
    //     button_list.push(button);
    //   }
    // });

    this.model.ui.buttons = button_list;

    //add buttons and render components
    this._addButtons(button_list, button_expand);

    //store body overflow
    this._prev_body_overflow = document.body.style.overflow;

    this.setBubbleTrails();
    this.setTimeForecast();
    this.setBubbleLock();
    this.setInpercent();
    this.setPresentationMode();

    this._toggleButtons();

  },

  proceedClick(id) {
    const _this = this;
    const btn = _this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");
    const classes = btn.attr("class");
    const btn_config = _this._available_buttons[id];

    if (btn_config && btn_config.func) {
      btn_config.func(id);
    } else {
      const btn_active = classes.indexOf(class_active) === -1;

      btn.classed(class_active, btn_active);
      const evt = {};
      evt["id"] = id;
      evt["active"] = btn_active;
      _this.trigger("click", evt);
    }
  },

  validatePopupButtons(buttons, dialogs) {
    const _this = this;

    const popupDialogs = dialogs.popup;
    const popupButtons = buttons.filter(d => (_this._available_buttons[d] && !_this._available_buttons[d].func));
    for (let i = 0, j = popupButtons.length; i < j; i++) {
      if (popupDialogs.indexOf(popupButtons[i]) == -1) {
        return utils.error('Buttonlist: bad buttons config: "' + popupButtons[i] + '" is missing in popups list');
      }
    }
    return false; //all good
  },

  /*
   * reset buttons show state
   */
  _showAllButtons() {
    // show all existing buttons
    const _this = this;
    const buttons = this.element.selectAll(".vzb-buttonlist-btn");
    buttons.each(function(d, i) {
      const button = d3.select(this);
      button.style("display", "");
    });
  },

  /*
  * determine which buttons are shown on the buttonlist
  */
  _toggleButtons() {
    const _this = this;
    const parent = this.parent.element.node ? this.parent.element : d3.select(this.parent.element);

    //HERE
    const button_expand = (this.model.ui.dialogs || {}).sidebar || [];
    _this._showAllButtons();

    const buttons = this.element.selectAll(".vzb-buttonlist-btn");

    const container = this.element.node().getBoundingClientRect();

    const not_required = [];
    const required = [];

    let button_width = 80;
    let button_height = 80;
    let container_width = this.element.node().getBoundingClientRect().width;
    let container_height = this.element.node().getBoundingClientRect().height;
    let buttons_width = 0;
    let buttons_height = 0;

    buttons.filter(d => !d.ignoreSize).each(function(d, i) {
      const button_data = d;
      const button = d3.select(this);
      const expandable = button_expand.indexOf(button_data.id) !== -1;
      const button_margin = { top: parseInt(button.style("margin-top")), right: parseInt(button.style("margin-right")), left: parseInt(button.style("margin-left")), bottom: parseInt(button.style("margin-bottom")) };
      button_width = button.node().getBoundingClientRect().width + button_margin.right + button_margin.left;
      button_height = button.node().getBoundingClientRect().height + button_margin.top + button_margin.bottom;

      if (!button.classed(class_hidden)) {
        if (!expandable || _this.getLayoutProfile() !== "large" || _this.model.ui.sidebarCollapse) {
          buttons_width += button_width;
          buttons_height += button_height;
          //sort buttons between required and not required buttons.
          // Not required buttons will only be shown if there is space available
          if (button_data.required) {
            required.push(button);
          } else {
            not_required.push(button);
          }
        } else {
          button.style("display", "none");
        }
      }
    });
    const width_diff = buttons_width - container_width;
    const height_diff = buttons_height - container_height;
    let number_of_buttons = 1;

    //check if container is landscape or portrait
    // if portrait small or large with expand, use width
    if (parent.classed("vzb-large") && parent.classed("vzb-dialog-expand-true")
    || parent.classed("vzb-small") && parent.classed("vzb-portrait")) {
      //check if the width_diff is small. If it is, add to the container
      // width, to allow more buttons in a way that is still usable
      if (width_diff > 0 && width_diff <= 10) {
        container_width += width_diff;
      }
      number_of_buttons = Math.floor(container_width / button_width) - required.length;
      if (number_of_buttons < 0) {
        number_of_buttons = 0;
      }
    // else, use height
    } else {
      //check if the width_diff is small. If it is, add to the container
      // width, to allow more buttons in a way that is still usable
      if (height_diff > 0 && height_diff <= 10) {
        container_height += height_diff;
      }
      number_of_buttons = Math.floor(container_height / button_height) - required.length;
      if (number_of_buttons < 0) {
        number_of_buttons = 0;
      }
    }
    //change the display property of non required buttons, from right to
    // left
    not_required.reverse();
    const hiddenButtons = [];
    for (let i = 0, j = not_required.length - number_of_buttons; i < j; i++) {
      not_required[i].style("display", "none");
      hiddenButtons.push(not_required[i].attr("data-btn"));
    }

    const evt = {};
    evt["hiddenButtons"] = hiddenButtons;
    _this.trigger("toggle", evt);

  },

  /*
   * adds buttons configuration to the components and template_data
   * @param {Array} button_list list of buttons to be added
   */
  _addButtons(button_list, button_expand) {
    const _this = this;
    this._components_config = [];
    const details_btns = [];
    if (!button_list.length) return;
    //add a component for each button
    for (let i = 0; i < button_list.length; i++) {

      const btn = button_list[i];
      const btn_config = this._available_buttons[btn];

      //add template data
      const d = (btn_config) ? btn : "_default";
      const details_btn = utils.clone(this._available_buttons[d]);
      if (d == "_default") {
        details_btn.title = "buttons/" + btn;
      }
      details_btn.id = btn;
      details_btn.icon = iconset[details_btn.icon];
      details_btns.push(details_btn);
    }

    const t = this.getTranslationFunction(true);

    this.element.selectAll("button").data(details_btns)
      .enter().append("button")
      .attr("class", d => {
        let cls = "vzb-buttonlist-btn";
        if (button_expand.length > 0) {
          if (button_expand.indexOf(d.id) > -1) {
            cls += " vzb-dialog-side-btn";
          }
        }

        return cls;
      })
      .attr("data-btn", d => d.id)
      .html(btn => "<span class='vzb-buttonlist-btn-icon fa'>" +
          btn.icon + "</span><span class='vzb-buttonlist-btn-title'>" +
          t(btn.title) + "</span>");

    const buttons = this.element.selectAll(".vzb-buttonlist-btn");

    //clicking the button
    buttons.on("click", function() {

      d3.event.preventDefault();
      d3.event.stopPropagation();

      const id = d3.select(this).attr("data-btn");
      _this.proceedClick(id);
    });

  },


  scrollToEnd() {
    let target = 0;
    const parent = d3.select(".vzb-tool");

    if (parent.classed("vzb-portrait") && parent.classed("vzb-small")) {
      if (this.model.state.marker.select.length > 0) target = this.element.node().scrollWidth;
      this.element.node().scrollLeft = target;
    } else {
      if (this.model.state.marker.select.length > 0) target = this.element.node().scrollHeight;
      this.element.node().scrollTop = target;
    }
  },


  /*
   * RESIZE:
   * Executed whenever the container is resized
   * Ideally, it contains only operations related to size
   */
  resize() {
    //TODO: what to do when resizing?
    if (!this.element.selectAll) return utils.warn("buttonlist resize() aborted because element is not yet defined");

    //toggle presentaion off is switch to 'small' profile
    if (this.getLayoutProfile() === "small" && this.model.ui.presentation) {
      this.togglePresentationMode();
    }

    this._toggleButtons();
  },

  setButtonActive(id, boolActive) {
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");

    btn.classed(class_active, boolActive);
  },

  toggleSidebarCollapse() {
    this.model.ui.sidebarCollapse = !this.model.ui.sidebarCollapse;
    this.setSidebarCollapse();
  },

  setSidebarCollapse() {
    const rootEl = d3.select(this.root.element);
    if (rootEl.classed("vzb-dialog-expand-true") == this.model.ui.sidebarCollapse) {
      rootEl.classed("vzb-dialog-expand-true", !this.model.ui.sidebarCollapse);
      this.root.trigger("resize");
    }
  },

  toggleBubbleTrails() {
    this.model.ui.chart.trails = !this.model.ui.chart.trails;
    this.setBubbleTrails();
  },
  setBubbleTrails() {
    const trails = (this.model.ui.chart || {}).trails;
    if (!trails && trails !== false) return;
    const id = "trails";
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");
    if (!btn.node()) return utils.warn("setBubbleTrails: no button '" + id + "' found in DOM. doing nothing");

    btn.classed(class_active_locked, trails);
    btn.classed(class_hidden, this.model.state.marker.select.length == 0);
  },
  toggleTimeForecast() {
    this.model.state.time.showForecast = !this.model.state.time.showForecast;
    this.setTimeForecast();
  },
  setTimeForecast() {
    const showForecast = (this.model.state.time || {}).showForecast;
    if (!showForecast && showForecast !== false) return;
    const id = "forecast";
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");
    if (!btn.node()) return utils.warn("setBubbleTrails: no button '" + id + "' found in DOM. doing nothing");

    btn.classed(class_active_locked, showForecast);
    btn.classed(class_hidden, !this.model.state.time.endBeforeForecast);
  },
  toggleBubbleLock(id) {
    const active = (this.model.ui.chart || {}).lockActive;

    if (this.model.state.marker.select.length == 0 && !active) return;

    let locked = this.model.ui.chart.lockNonSelected;
    const time = this.model.state.time;
    locked = locked ? 0 : time.formatDate(time.value);
    this.model.ui.chart.lockNonSelected = locked;

    this.setBubbleLock();
  },
  setBubbleLock() {
    let locked = (this.model.ui.chart || {}).lockNonSelected;
    const active = (this.model.ui.chart || {}).lockActive;
    const unavailable = (this.model.ui.chart || {}).lockUnavailable || false;
    if (!locked && locked !== 0) return;

    if (locked !== 0 && this.model.state.marker.select.length === 0 && !active) {
      locked = this.model.ui.chart.lockNonSelected = 0;
    }

    const id = "lock";
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");
    if (!btn.node()) return utils.warn("setBubbleLock: no button '" + id + "' found in DOM. doing nothing");

    const translator = this.model.locale.getTFunction();

    //btn.classed(class_unavailable, this.model.state.marker.select.length == 0 && !active);
    btn.classed(class_unavailable, unavailable);
    if (typeof active === "undefined") {
      btn.classed(class_hidden, this.model.state.marker.select.length == 0);
    } else {
      btn.classed(class_hidden, !active);
    }

    btn.classed(class_active_locked, locked);

    btn.select(".vzb-buttonlist-btn-icon")
      .html(iconset[locked ? "lock" : "unlock"]);

    btn.select(".vzb-buttonlist-btn-title>span").text(
      locked ? locked : translator("buttons/lock")
    )
      .attr("data-vzb-translate", locked ? null : "buttons/lock");
  },
  toggleInpercent() {
    this.model.ui.chart.inpercent = !this.model.ui.chart.inpercent;
    this.setInpercent();
  },
  setInpercent() {
    if (typeof ((this.model.ui.chart || {}).inpercent) === "undefined") return;
    const id = "inpercent";
    const translator = this.model.locale.getTFunction();
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");

    btn.classed(class_active_locked, this.model.ui.chart.inpercent);
  },
  togglePresentationMode() {
    this.model.ui.presentation = !this.model.ui.presentation;
    this.setPresentationMode();
  },
  setPresentationMode() {
    const id = "presentation";
    const translator = this.model.locale.getTFunction();
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");

    btn.classed(class_active_locked, this.model.ui.presentation);
  },
  toggleFullScreen(id, emulateClick) {

    if (!window) return;

    let component = this;
    let pholder = component.placeholder;
    let pholder_found = false;
    const btn = this.element.selectAll(".vzb-buttonlist-btn[data-btn='" + id + "']");
    const fs = !this.model.ui.fullscreen;
    const body_overflow = (fs) ? "hidden" : this._prev_body_overflow;

    while (!(pholder_found = utils.hasClass(pholder, "vzb-placeholder"))) {
      component = component.parent;
      pholder = component.placeholder;
    }

    //TODO: figure out a way to avoid fullscreen resize delay in firefox
    if (fs) {
      this.resizeInExitHandler = false;
      launchIntoFullscreen(pholder);
      subscribeFullscreenChangeEvent.call(this, this.toggleFullScreen.bind(this, id, true));
    } else {
      this.resizeInExitHandler = !emulateClick;
      exitFullscreen.call(this);
    }
    utils.classed(pholder, class_vzb_fullscreen, fs);
    if (typeof container !== "undefined") {
      utils.classed(container, class_container_fullscreen, fs);
    }

    this.model.ui.fullscreen = fs;
    const translator = this.model.locale.getTFunction();
    btn.classed(class_active_locked, fs);

    btn.select(".vzb-buttonlist-btn-icon").html(iconset[fs ? "unexpand" : "expand"]);

    btn.select(".vzb-buttonlist-btn-title>span").text(
      translator("buttons/" + (fs ? "unexpand" : "expand"))
    )
      .attr("data-vzb-translate", "buttons/" + (fs ? "unexpand" : "expand"));

    //restore body overflow
    document.body.style.overflow = body_overflow;

    if (!this.resizeInExitHandler) this.root.ui.resizeHandler();

    //force window resize event
    // utils.defer(function() {
    //   event = window.document.createEvent("HTMLEvents");
    //   event.initEvent("resize", true, true);
    //   event.eventName = "resize";
    //   window.dispatchEvent(event);
    // });
  }

});

function isFullscreen() {
  if (!window) return false;
  if (window.document.webkitIsFullScreen !== undefined)
    return window.document.webkitIsFullScreen;
  if (window.document.mozFullScreen !== undefined)
    return window.document.mozFullScreen;
  if (window.document.msFullscreenElement !== undefined)
    return window.document.msFullscreenElement;

  return false;
}

function exitHandler(emulateClickFunc) {
  if (!isFullscreen()) {
    removeFullscreenChangeEvent.call(this);
    if (!this.resizeInExitHandler) {
      emulateClickFunc();
    } else {
      this.root.ui.resizeHandler();
    }
  }
}

function subscribeFullscreenChangeEvent(exitFunc) {
  if (!window) return;
  const doc = window.document;

  this.exitFullscreenHandler = exitHandler.bind(this, exitFunc);
  doc.addEventListener("webkitfullscreenchange", this.exitFullscreenHandler, false);
  doc.addEventListener("mozfullscreenchange", this.exitFullscreenHandler, false);
  doc.addEventListener("fullscreenchange", this.exitFullscreenHandler, false);
  doc.addEventListener("MSFullscreenChange", this.exitFullscreenHandler, false);
}

function removeFullscreenChangeEvent() {
  const doc = window.document;

  doc.removeEventListener("webkitfullscreenchange", this.exitFullscreenHandler);
  doc.removeEventListener("mozfullscreenchange", this.exitFullscreenHandler);
  doc.removeEventListener("fullscreenchange", this.exitFullscreenHandler);
  doc.removeEventListener("MSFullscreenChange", this.exitFullscreenHandler);
}

function launchIntoFullscreen(elem) {
  if (elem.requestFullscreen) {
    elem.requestFullscreen();
  } else if (elem.msRequestFullscreen) {
    elem.msRequestFullscreen();
  } else if (elem.mozRequestFullScreen) {
    elem.mozRequestFullScreen();
  } else if (elem.webkitRequestFullscreen && allowWebkitFullscreenAPI()) {
    elem.webkitRequestFullscreen();
  }
}

function exitFullscreen() {
  if (document.exitFullscreen) {
    document.exitFullscreen();
  } else if (document.msExitFullscreen) {
    document.msExitFullscreen();
  } else if (document.mozCancelFullScreen) {
    document.mozCancelFullScreen();
  } else if (document.webkitExitFullscreen && allowWebkitFullscreenAPI()) {
    document.webkitExitFullscreen();
  } else {
    removeFullscreenChangeEvent.call(this);
    this.resizeInExitHandler = false;
  }
}

function allowWebkitFullscreenAPI() {
  return !(navigator.vendor && navigator.vendor.indexOf("Apple") > -1 &&
    navigator.userAgent && !navigator.userAgent.match("CriOS"));
}

export default ButtonList;