Gapminder/vizabi

View on GitHub
src/models/marker.js

Summary

Maintainability
F
5 days
Test Coverage
import * as utils from "base/utils";
import Model from "base/model";

/*!
 * HOOK MODEL
 */

const Marker = Model.extend({

  getClassDefaults() {
    const defaults = {
      select: [],
      highlight: [],
      superHighlight: [],
      opacityHighlightDim: 0.1,
      opacitySelectDim: 0.3,
      opacityRegular: 1,
      limit: 1000,
      allowSelectMultiple: true
    };
    return utils.deepExtend(this._super(), defaults);
  },

  init(name, value, parent, binds, persistent) {
    const _this = this;

    this._type = "marker";
    this._visible = [];

    this._super(name, value, parent, binds, persistent);

    this.on("change", "space", this.setInterModelListeners.bind(this));
  },

  setInterModelListeners() {
    utils.forEach(this.getSpace(), reference => {
      // make reference to dimension
      this._space[reference] = this.getClosestModel(reference);
    });
    this._super();
  },

  /**
   * Quietly sets this marker's first entity to have a different dimension,
   * equal to "which" of model that initiated the sync.
   * Needed for example for color legend, when by changing
   * color from one entity set to a different entity set,
   * the legend should request new labels and minimap shapes
   * for the new entity set. Possibly from a different datasource too.
   */
  _receiveSyncModelUpdate(sourceMdl) {
    const conceptType = sourceMdl.getConceptprops().concept_type;
    if (["entity_set", "entity_domain"].includes(conceptType)) {

      const newFilter = {
        dim: sourceMdl.which,
        //copy filter for which matching individual entities
        //otherwise reset filter
        filter: sourceMdl.which === sourceMdl._getFirstDimension() ?
          utils.clone(sourceMdl.getEntity().filter) : {}
      };
      this.setDataSourceForAllSubhooks(sourceMdl.data);
      this.getFirstEntityModel().set(newFilter, false, false);
    }
  },

  /**
   * A shorthand for one-dimensional situations
   * allows to get quickly to the entity model of this marker
   */
  getFirstEntityModel() {
    return this._space[this.space[0]];
  },


  setSpace(newSpace) {
    const subHooks = Object.keys(this.getSubhooks(true));
    const setProps = {};
    const setWhichProps = {};
    const newDimModels = setProps["space"] = this._root.dimensionManager.getDimensionModelsForSpace(this._space, newSpace);
    const addedDimModels = newDimModels.filter(f => !this.space.includes(f));
    addedDimModels.forEach(dimensionModel => {
      const dimModel = this.getClosestModel(dimensionModel);
      const labelModelName = "label" + dimensionModel.replace(dimModel._type, "");
      const props = { which: dimModel.dim, use: "property" };
      //change which to 'name' if 'name' property available for dimension
      const nameData = this._root.dataManager.getAvailableDataForKey(dimModel.dim, "name")[0];
      if (nameData) {
        props.which = nameData.value;
        props.data = nameData.data;
      }
      if (subHooks.includes(labelModelName)) {
        props.spaceRef = dimensionModel;
        setProps[labelModelName] = props;
      } else {
        props.key = [{ concept: dimModel.dim }];
        props.dataSource = props.data;
        props.concept = props.which;
        setWhichProps[labelModelName] = props;
        setProps[labelModelName] = {};
      }
    });
    this.set(setProps);

    utils.forEach(setWhichProps, (props, hookName) => {
      this[hookName].setInterModelListeners();
      this[hookName].setWhich(props);
    });

    this._dataCube = this.getSubhooks(true);
  },

  getAvailableSpaces() {
    const spaces = new Map();
    utils.forEach(this._root._data, dataSource => {
      if (dataSource._type !== "data") return;

      const indicatorsDB = dataSource.getConceptprops();

      dataSource.keyAvailability.forEach((space, str) => {
        if (space.length > 1) { // supported dimensions might later depend on tool.
          spaces.set(str, space.map(dimension => indicatorsDB[dimension]));
        }
      });
    });
    return spaces;
  },

  getAvailableData() {

    if (d3.keys(this._space).length === 0) return utils.warn("getAvailableData() is trying to access missing _space items of marker '" + this._name + "' which likely haven't been resoled in time");
    const dimensions = utils.unique(this.space.map(dim => this._space[dim].dim));

    const availability = this._root.dataManager.getAvailabilityForMarkerKey(dimensions);

    // just first dataModel, can lead to problems if first data source doesn't contain dim-concept
    const firstDataModel = this._root.dataManager.getDataModels().values().next().value;
    dimensions
      .filter(dim => dim != null)
      .forEach(dim => availability.push({
        key: [firstDataModel.getConceptprops(dim)],
        value: firstDataModel.getConceptprops(dim),
        dataSource: firstDataModel
      }));
    availability.push({
      key: [firstDataModel.getConceptprops("_default")],
      value: firstDataModel.getConceptprops("_default"),
      dataSource: firstDataModel
    });

    return availability;
  },


  getAvailableConcept({ index: index = 0, type: type = null, includeOnlyIDs: includeOnlyIDs = [], excludeIDs: excludeIDs = [] } = { }) {
    if (!type && includeOnlyIDs.length == 0 && excludeIDs.length == 0) {
      return null;
    }

    const filtered = this.getAvailableData().filter(f =>
      (!type || !f.value.concept_type || f.value.concept_type === type)
      && (includeOnlyIDs.length == 0 || includeOnlyIDs.indexOf(f.value.concept) !== -1)
      && (excludeIDs.length == 0 || excludeIDs.indexOf(f.value.concept) == -1)
    );
    return filtered[index] || filtered[filtered.length - 1];
  },

  setDataSourceForAllSubhooks(data) {
    const obj = {};
    this.getSubhooks().forEach(hook => { obj[hook._name] = { data }; });
    this.set(obj, null, false);
  },


  /**
   * Validates the model
   */
  validate() {
    const _this = this;
    const dimension = this.getDimension();
    const visible_array = this._visible.map(d => d[dimension]);

    if (visible_array.length) {
      this.select = this.select.filter(f => visible_array.indexOf(f[dimension]) !== -1);
      this.setHighlight(this.highlight.filter(f => visible_array.indexOf(f[dimension]) !== -1));
    }
  },

  /**
   * Sets the visible entities
   * @param {Array} arr
   */
  setVisible(arr) {
    this._visible = arr;
  },

  /**
   * Gets the visible entities
   * @returns {Array} visible
   */
  getVisible(arr) {
    return this._visible;
  },

  /**
   * Gets the selected items
   * @returns {Array} Array of unique selected values
   */
  getSelected(dim) {
    return dim ? this.select.map(d => d[dim]) : this.select;
  },

  selectMarker(d) {
    const _this = this;
    const value = this._createValue(d);
    if (this.isSelected(d)) {
      this.select = this.select.filter(d => JSON.stringify(_this._createValue(d)) !== JSON.stringify(value));
    } else {
      this.select = (this.allowSelectMultiple) ? this.select.concat(value) : [value];
    }
  },

  /**
   * Select all entities
   */
  selectAll(timeDim, timeFormatter) {
    if (!this.allowSelectMultiple) return;

    let added;
    const dimensions = utils.unique(this._getAllDimensions({ exceptType: "time" }));

    this.select = this._visible.map(d => {
      added = {};
      dimensions.forEach(dimension => added[dimension] = d[dimension]);
      return added;
    });
  },

  isSelected(d) {
    const _this = this;
    const value = JSON.stringify(this._createValue(d));

    return this.select
      .map(d => JSON.stringify(_this._createValue(d)) === value)
      .indexOf(true) !== -1;
  },

  _createValue(d) {
    const dims = this._getAllDimensions({ exceptType: "time" });
    return dims.reduce((value, key) => {
      value[key] = d[key];
      return value;
    }, {});
  },


  /**
   * Gets the highlighted items
   * @returns {Array} Array of unique highlighted values
   */
  getHighlighted(dim) {
    return dim ? this.highlight.map(d => d[dim]) : this.highlight;
  },

  setHighlight(arg) {
    if (!utils.isArray(arg)) {
      this.setHighlight([].concat(arg));
      return;
    }
    this.getModelObject("highlight").set(arg, false, false); // highlights are always non persistent changes
  },

  setSuperHighlight(value) {
    this.getModelObject("superHighlight")
      .set(utils.isArray(value) ? value : [value], false, false);
  },

  clearSuperHighlighted() {
    this.setSuperHighlight([]);
  },

  isSuperHighlighted(d) {
    const value = JSON.stringify(this._createValue(d));

    return ~this.superHighlight.findIndex(d => JSON.stringify(d) === value);
  },

  setSelect(arg) {
    if (!utils.isArray(arg)) {
      this.setSelect([].concat(arg));
      return;
    }
    this.getModelObject("select").set(arg);
  },

  //TODO: join the following 3 methods with the previous 3

  /**
   * Highlights an entity from the set
   */
  highlightMarker(d) {
    const value = this._createValue(d);
    if (!this.isHighlighted(d)) {
      this.setHighlight(this.highlight.concat(value));
    }
  },

  /**
   * Unhighlights an entity from the set
   */
  unhighlightEntity(d) {
    const value = this._createValue(d);
    if (this.isHighlighted(d)) {
      this.setHighlight(this.highlight.filter(d => d[dimension] !== value));
    }
  },

  /**
   * Checks whether an entity is highlighted from the set
   * @returns {Boolean} whether the item is highlighted or not
   */
  isHighlighted(d) {
    const _this = this;
    const value = JSON.stringify(this._createValue(d));
    return this.highlight
      .map(d => JSON.stringify(_this._createValue(d)) === value)
      .indexOf(true) !== -1;
  },

  /**
   * Clears selection of items
   */
  clearHighlighted() {
    this.setHighlight([]);
  },
  clearSelected() {
    this.select = [];
  },

  setLabelOffset(d, xy) {
    if (xy[0] === 0 && xy[1] === 1) return;

    const KEYS = utils.unique(this._getAllDimensions({ exceptType: "time" }));
    const KEY = KEYS.join(",");

    this.select
      .find(selectedMarker => utils.getKey(selectedMarker, KEYS) == d[KEY])
      .labelOffset = [Math.round(xy[0] * 1000) / 1000, Math.round(xy[1] * 1000) / 1000];

    //force the model to trigger events even if value is the same
    //this.set("select", this.select, true);
    this.getModelObject("select").trigger("change", "select.labelOffset");
  },

  getImportantHooks() {
    const importantHooks = [];
    utils.forEach(this._dataCube || this.getSubhooks(true), (hook, name) => {
      if (hook._important) {
        importantHooks.push(name);
      }
    });
    return importantHooks;
  },

  getLabelHookNames() {
    const _this = this;
    const KEYS = utils.unique(this._getAllDimensions({ exceptType: "time" }));

    return KEYS.reduce((result, key) => {
      const names = {};
      utils.forEach(_this._dataCube || _this.getSubhooks(true), (hook, name) => {
        if (!hook.isDiscrete()) return;
        if (hook._type === "label" && hook.getEntity().dim === key) {
          names.label = name;
        }
        if (hook._type !== "label" && hook.getEntity().dim === key) {
          names.key = name;
        }
        return !names.label || !names.key;
      });
      const name = names.label || names.key;
      if (name) result[key] = name;
      return result;
    }, {});
  },

  getDataKeysPerHook() {
    const result = {};
    utils.forEach(this._dataCube || this.getSubhooks(true), (hook, name) => {
      result[name] = hook.getDataKeys();
    });
    return result;
  },

  /**
   * Computes the intersection of keys in all hooks: a set of keys that have data in each hook
   * @returns array of keys that have data in all hooks of this._datacube
   */
  getKeys(KEYS) {
    const _this = this;
    let resultKeys;

    KEYS = KEYS || utils.unique(this._getAllDimensions({ exceptType: "time" }));
    KEYS = Array.isArray(KEYS) ? KEYS : [KEYS];
    const TIME = this._getFirstDimension({ type: "time" });

    const subHooks = this._dataCube || this.getSubhooks(true);

    const hooksPerKey = KEYS.map(_ => []);
    const dataSourcePerKey = KEYS.map(_ => []);
    //try to find hooks with entity queries for each subkey of KEYS
    utils.forEach(subHooks, (hook, name) => {
      if (hook.use === "property") {
        const keyIndex = KEYS.indexOf(hook.getEntity().dim);
        if (keyIndex !== -1 && !dataSourcePerKey[keyIndex].includes(hook.dataSource)) {
          hooksPerKey[keyIndex].push(hook);
          dataSourcePerKey[keyIndex].push(hook.dataSource);
        }
      }
    });

    //try to get keys from indicators if marker does not have hooks with entity queries
    //in each dataSource for some subkey of KEYS
    utils.forEach(subHooks, (hook, name) => {
      if (hook.use === "indicator") {
        hook.getDataKeys().forEach(key => {
          const keyIndex = KEYS.indexOf(key);
          if (keyIndex !== -1 && !dataSourcePerKey[keyIndex].includes(hook.dataSource)) {
            hooksPerKey[keyIndex].push(hook);
          }
        });
      }
    });

    hooksPerKey.forEach((hooks, keyIndex) => {
      let keys = [];
      hooks.forEach(hook => {
        const hookKeys = hook.getDataKeys();
        const hookKeyIndex = hookKeys.indexOf(KEYS[keyIndex]);
        keys = keys.concat(Object.keys(hook.getNestedItems(hookKeys.concat(TIME))).map(key => [JSON.parse(key)[hookKeyIndex]]));
      });
      keys = utils.unique(keys);
      resultKeys = resultKeys ? d3.cross(resultKeys, keys, (a, b) => a.concat(b)) : keys;
    });

    utils.forEach(subHooks, (hook, name) => {
      // If hook use is constant, then we can provide no additional info about keys
      // We can just hope that we have something else than constants =)
      if (!hook._important || hook.use === "constant") return;

      const hookKEYS = hook.getDataKeys();
      const hookKEYSIndexes = hookKEYS.map(key => KEYS.indexOf(key)).reduce((indexes, index, i) => {
        if (index !== -1) indexes[i] = index;
        return indexes;
      }, []);

      if (!hookKEYSIndexes.length) return;

      // Get keys in data of this hook
      const nested = hook.getNestedItems(hookKEYS.concat(TIME));
      const noDataPoints = hook.getHaveNoDataPointsPerKey();

      const keys = Object.keys(nested);
      const keysNoDP = Object.keys(noDataPoints || []);

      // Remove the keys with no timepoints
      const keysSizeEqual = KEYS.every((key, i) => key === hookKEYS[i]);
      const filteredKeys = keys.reduce((keys, key) => {
        if (keysNoDP.indexOf(key) == -1) keys[JSON.stringify(hookKEYSIndexes.map((_, i) => JSON.parse(key)[i]))] = true;
        return keys;
      }, {});

      const resultKeysMapped = resultKeys.map(key => JSON.stringify(hookKEYSIndexes.map(index => key[index])));

      resultKeys = resultKeys.filter((_, i) => filteredKeys[resultKeysMapped[i]]);
    });

    if (resultKeys.length > _this.limit) {
      utils.warn("MARKER getKeys(): only showing the first " + _this.limit + " markerElements of " + _this._name + ". The rest are not displayed because chart may become slow and crash. Set a higher number in marker.limit or apply entity filters");
      resultKeys = resultKeys.slice(0, _this.limit);
    }
    return resultKeys.map(key => { const r = {}; KEYS.map((KEY, i) => r[KEY] = key[i]); return r; });
  },

  /**
   * @param {Array} entities array of entities
   * @return String
   */
  _getCachePath(keys) {
    //array of steps -- names of all frames
    const steps = this._parent.time.getAllSteps();
    let cachePath = `${this.getClosestModel("locale").id} - ${steps[0]} - ${steps[steps.length - 1]} - step:${this._parent.time.step}`;
    this._dataCube = this._dataCube || this.getSubhooks(true);
    let dataLoading = false;
    utils.forEach(this._dataCube, (hook, name) => {
      if (hook._loadCall) dataLoading = true;
      cachePath = cachePath + "_" +  hook._dataId + hook.which;
    });
    if (dataLoading) {
      return null;
    }
    if (keys) {
      cachePath = cachePath + "_" + keys.join(",");
    }
    return cachePath;
  },

  _getGrouping() {
    const subHooks = this._dataCube || this.getSubhooks(true);
    const space = subHooks[Object.keys(subHooks)[0]]._space;
    const result = {};
    utils.forEach(space, entities => {
      if (entities.grouping) {
        result[entities.dim] = { grouping: entities.grouping };
      }
    });
    return utils.isEmpty(result) ? false : result;
  },

  _getAllDimensions(opts) {

    const models = [];
    const _this = this;
    utils.forEach(this.space, name => {
      models.push(_this.getClosestModel(name));
    });

    opts = opts || {};
    const dims = [];
    let dim;

    utils.forEach(models, m => {
      if (opts.exceptType && m.getType() === opts.exceptType) {
        return true;
      }
      if (opts.onlyType && m.getType() !== opts.onlyType) {
        return true;
      }
      if (dim = m.getDimension()) {
        dims.push(dim);
      }
    });

    return dims;
  },


  /**
   * gets first dimension that matches type
   * @param {Object} options
   * @returns {Array} all unique dimensions
   */
  _getFirstDimension(opts) {
    const models = [];
    const _this = this;
    utils.forEach(this.space, name => {
      models.push(_this.getClosestModel(name));
    });

    opts = opts || {};

    let dim = false;
    utils.forEach(models, m => {
      if (opts.exceptType && m.getType() !== opts.exceptType) {
        dim = m.getDimension();
        return false;
      } else if (opts.type && m.getType() === opts.type) {
        dim = m.getDimension();
        return false;
      } else if (!opts.exceptType && !opts.type) {
        dim = m.getDimension();
        return false;
      }
    });
    return dim;
  },


  framesAreReady() {
    const cachePath = this._getCachePath();
    if (!this.cachedFrames) return false;
    return Object.keys(this.cachedFrames[cachePath]).length == this._parent.time.getAllSteps().length;
  },

  /**
   *
   * @param {String|null} time of a particularly requested data frame. Null if all frames are requested
   * @param {function} cb
   * @param {Array} keys array of entities
   * @return null
   */
  getFrame(time, cb, keys) {
    //keys = null;
    const _this = this;
    if (!this.cachedFrames) this.cachedFrames = {};

    const steps = this._parent.time.getAllSteps();
    // try to get frame from cache without keys
    let cachePath = this._getCachePath();
    if (!cachePath) return cb(null, time);
    if (time && _this.cachedFrames[cachePath] && _this.cachedFrames[cachePath][time]) {
      // if it does, then return that frame directly and stop here
      //QUESTION: can we call the callback and return the frame? this will allow callbackless API too
      return cb(_this.cachedFrames[cachePath][time], time);
    }
    cachePath = this._getCachePath(keys);
    if (!cachePath) return cb(null, time);

    // check if the requested time point has a cached animation frame
    if (time && _this.cachedFrames[cachePath] && _this.cachedFrames[cachePath][time]) {
      // if it does, then return that frame directly and stop here
      //QUESTION: can we call the callback and return the frame? this will allow callbackless API too
      return cb(_this.cachedFrames[cachePath][time], time);
    }

    // if it doesn't (the requested time point falls between animation frames or frame is not cached yet)
    // check if interpolation makes sense: we've requested a particular time and we have more than one frame
    if (time && steps.length > 1) {

      //find the next frame after the requested time point
      const nextFrameIndex = d3.bisectLeft(steps, time);

      if (!steps[nextFrameIndex]) {
        utils.warn("The requested frame is out of range: " + time);
        cb(null, time);
        return null;
      }

      //if "time" doesn't hit the frame precisely
      if (steps[nextFrameIndex].toString() != time.toString()) {

        //interpolate between frames and fire the callback
        this._interpolateBetweenFrames(time, nextFrameIndex, steps, response => {
          cb(response, time);
        }, keys);
        return null;
      }
    }

    //QUESTION: we don't need any further execution after we called for interpolation, right?
    //request preparing the data, wait until it's done
    _this.getFrames(time, keys).then(() => {
      if (!time && _this.cachedFrames[cachePath]) {
        //time can be null: then return all frames
        return cb(_this.cachedFrames[cachePath], time);
      } else if (_this.cachedFrames[cachePath] && _this.cachedFrames[cachePath][time]) {
        //time can be !null: then a particular frame calculation was forced and now it's done
        return cb(_this.cachedFrames[cachePath][time], time);
      }
      utils.warn("marker.js getFrame: Data is not available for frame: " + time);
      return cb(null, time);
    });
  },

  _interpolateBetweenFrames(time, nextFrameIndex, steps, cb, keys) {
    const _this = this;

    if (nextFrameIndex == 0) {
      //getFrame makes sure the frane is ready because a frame with non-existing data might be adressed
      this.getFrame(steps[nextFrameIndex], values => cb(values), keys);
    } else {
      const prevFrameTime = steps[nextFrameIndex - 1];
      const nextFrameTime = steps[nextFrameIndex];

      //getFrame makes sure the frane is ready because a frame with non-existing data might be adressed
      this.getFrame(prevFrameTime, pValues => {
        _this.getFrame(nextFrameTime, nValues => {
          const fraction = (time - prevFrameTime) / (nextFrameTime - prevFrameTime);
          const dataBetweenFrames = {};

          //loop across the hooks
          utils.forEach(pValues, (values, hook) => {
            dataBetweenFrames[hook] = {};

            //loop across the entities
            utils.forEach(values, (val1, key) => {
              const val2 = nValues[hook][key];
              if (utils.isDate(val1)) {
                dataBetweenFrames[hook][key] = time;
              } else if (!utils.isNumber(val1)) {
                //we can be interpolating string values
                dataBetweenFrames[hook][key] = val1;
              } else {
                //interpolation between number and null should rerurn null, not a value in between (#1350)
                dataBetweenFrames[hook][key] = (val1 == null || val2 == null) ? null : val1 + ((val2 - val1) * fraction);
              }
            });
          });
          cb(dataBetweenFrames);

        }, keys);
      }, keys);
    }
  },

  getFrames(forceFrame, selected) {
    const _this = this;
    if (!this.cachedFrames) this.cachedFrames = {};

    const KEYS = utils.unique(this._getAllDimensions({ exceptType: "time" }));
    const TIME = this._getFirstDimension({ type: "time" });

    if (!this.frameQueues) this.frameQueues = {}; //static queue of frames
    if (!this.partialResult) this.partialResult = {};

    //array of steps -- names of all frames
    const steps = this._parent.time.getAllSteps();

    const cachePath = this._getCachePath(selected);
    if (!cachePath) return new Promise((resolve, reject) => { resolve(); });
    //if the collection of frames for this data cube is not scheduled yet (otherwise no need to repeat calculation)
    if (!this.frameQueues[cachePath] || !(this.frameQueues[cachePath] instanceof Promise)) {

      //this is a promise nobody listens to - it prepares all the frames we need without forcing any
      this.frameQueues[cachePath] = new Promise((resolve, reject) => {

        _this.partialResult[cachePath] = {};
        _this.partialResult[cachePath].timeOrConstantHooks = [];
        steps.forEach(t => { _this.partialResult[cachePath][t] = {}; });

        const deferredHooks = [];
        // Assemble data from each hook. Each frame becomes a vector containing the current configuration of hooks.
        // frame -> hooks -> entities: values
        utils.forEach(_this._dataCube, (hook, name) => {
          if (hook.use === "constant" || hook.which === TIME) {
            //data from hooks with use 'constant' or which 'time dimension' will be filled last
            _this.partialResult[cachePath].timeOrConstantHooks.push({ name, which: hook.which });
          } else if (KEYS.includes(hook.which)) {
            //special case: fill data with keys to data itself
            const items = hook.getValidItems();
            steps.forEach(t => {
              _this.partialResult[cachePath][t][name] = {};
              items.forEach(item => {
                _this.partialResult[cachePath][t][name][item[hook.which]] = item[hook.which];
              });
            });
          } else {
            //calculation of async frames is taken outside the loop
            //hooks with real data that needs to be fetched from datamanager
            deferredHooks.push(hook);
          }
        });

        //check if we have any data to get from datamanager
        if (deferredHooks.length > 0) {
          const promises = [];
          utils.forEach(deferredHooks, hook => {
            promises.push(new Promise((res, rej) => {
              // need to save the hook state before calling getFrames.
              // `hook` state might change between calling and resolving the call.
              // The result needs to be saved to the correct cache, so we need to save current hook state
              const currentHookState = {
                name: hook._name,
                which: hook.which
              };
              hook.getFrames(steps, selected).then(response => {
                utils.forEach(response, (frame, t) => {
                  _this.partialResult[cachePath][t][currentHookState.name] = frame[currentHookState.which];
                });
                res();
              });
            }));
          });
          Promise.all(promises).then(() => {
            fillFromTimeOrConstantHooks();
            _this.cachedFrames[cachePath] = _this.partialResult[cachePath];
            resolve();
          });
        } else {
          fillFromTimeOrConstantHooks();
          _this.cachedFrames[cachePath] = _this.partialResult[cachePath];
          resolve();
        }

      });
    }
    return new Promise((resolve, reject) => {
      if (steps.length < 2 || !forceFrame) {
        //wait until the above promise is resolved, then resolve the current promise
        _this.frameQueues[cachePath].then(() => {
          resolve(); //going back to getFrame(), to ".then"
        });
      } else {
        const promises = [];
        utils.forEach(_this._dataCube, (hook, name) => {
          //exception: we know that these are knonwn, no need to calculate these
          if (hook.use !== "constant" && !KEYS.includes(hook.which) && hook.which !== TIME) {
            (function(_hook, _name) {
              promises.push(new Promise((res, rej) => {
                _hook.getFrame(steps, forceFrame, selected).then(response => {
                  _this.partialResult[cachePath][forceFrame][_name] = response[forceFrame][_hook.which];
                  res();
                });
              }));
            })(hook, name); //isolate this () code with its own hook and name
          }
        });
        if (promises.length > 0) {
          Promise.all(promises).then(() => {
            fillFromTimeOrConstantHooks();
            if (!_this.cachedFrames[cachePath]) {
              _this.cachedFrames[cachePath] = {};
            }
            _this.cachedFrames[cachePath][forceFrame] = _this.partialResult[cachePath][forceFrame];
            resolve();
          });
        } else {
          resolve();
        }
      }
    });

    function fillFromTimeOrConstantHooks() {
      if (!_this.partialResult[cachePath].timeOrConstantHooks) return;

      const { timeOrConstantHooks } = _this.partialResult[cachePath];
      // Assemble the list of keys as an intersection of keys in all queries of all hooks
      const keys = _this.getKeys();

      //special case: fill data with time points or fill data with constant values
      timeOrConstantHooks.forEach(({ which, name }) => {
        const isTimeWhich = which === TIME;
        steps.forEach(t => {
          _this.partialResult[cachePath][t][name] = {};
          keys.forEach(key => {
            _this.partialResult[cachePath][t][name][utils.getKey(key, KEYS)] = isTimeWhich ? new Date(t) : which;
          });
        });
      });
      delete _this.partialResult[cachePath].timeOrConstantHooks;
    }

  },

  listenFramesQueue(keys, cb) {
    const _this = this;
    const KEYS = utils.unique(this._getAllDimensions({ exceptType: "time" }));
    const TIME = this._getFirstDimension({ type: "time" });
    const steps = this._parent.time.getAllSteps();
    const preparedFrames = {};
    this.getFrames();
    const dataIds = [];

    const stepsCount = steps.length;
    let isDataLoaded = false;

    utils.forEach(_this._dataCube, (hook, name) => {
      if (!(hook.use === "constant" || KEYS.includes(hook.which) || hook.which === TIME)) {
        if (!dataIds.includes(hook._dataId)) {
          dataIds.push(hook._dataId);

          hook.dataSource.listenFrame(hook._dataId, steps, keys, (dataId, time) => {
            const keyName = time.toString();
            if (typeof preparedFrames[keyName] === "undefined") preparedFrames[keyName] = [];
            if (!preparedFrames[keyName].includes(dataId)) preparedFrames[keyName].push(dataId);
            if (preparedFrames[keyName].length === dataIds.length)  {
              if (!isDataLoaded && stepsCount === Object.keys(preparedFrames).length) {
                isDataLoaded = true;
                _this.trigger("dataLoaded");
              }

              cb(time);
            }
          });
        }
      }
    });
  },

  getEntityLimits(entity) {
    const _this = this;
    const timePoints = this._parent.time.getAllSteps();
    const selectedEdgeTimes = [];
    const hooks = [];
    utils.forEach(_this.getSubhooks(), hook => {
      if (hook.use == "constant") return;
      if (hook._important) hooks.push(hook._name);
    });

    const findEntityWithCompleteHooks = function(values) {
      if (!values) return false;
      for (let i = 0, j = hooks.length; i < j; i++) {
        if (!(values[hooks[i]][entity] || values[hooks[i]][entity] === 0)) return false;
      }
      return true;
    };

    const findSelectedTime = function(iterator, findCB) {
      const point = iterator();
      if (point == null) return findCB(point);
      _this.getFrame(timePoints[point], values => {
        if (findEntityWithCompleteHooks(values)) {
          findCB(point);
        } else {
          findSelectedTime(iterator, findCB);
        }
      });
    };
    const promises = [];
    promises.push(new Promise((resolve, reject) => {

      //find startSelected time
      findSelectedTime((function() {
        const max = timePoints.length;
        let i = 0;
        return function() {
          return i < max ? i++ : null;
        };
      })(), point => {
        selectedEdgeTimes[0] = timePoints[point];
        resolve();
      });
    }));

    promises.push(new Promise((resolve, reject) => {

      //find endSelected time
      findSelectedTime((function() {
        let i = timePoints.length - 1;
        return function() {
          return i >= 0 ? i-- : null;
        };
      })(), point => {
        selectedEdgeTimes[1] = timePoints[point];
        resolve();
      });

    }));

    return Promise.all(promises).then(() => ({ "min": selectedEdgeTimes[0], "max": selectedEdgeTimes[1] }));
  },


  getCompoundLabelText(d, values) {
    const DATAMANAGER = this._root.dataManager;
    const KEYS = utils.unique(this._getAllDimensions({ exceptType: "time" }));
    const labelNames = this.getLabelHookNames();

    let text = KEYS
      .filter(key => d[key] !== DATAMANAGER.getConceptProperty(key, "totals_among_entities"))
      .map(key => values[labelNames[key]] && values[labelNames[key]][d[key]] || d[key])
      .join(", ");

    if (text === "") text = this._root.locale.getTFunction()("hints/grandtotal");

    return text;
  },


  /**
   * Learn what this model should hook to
   * @returns {Array} space array
   */
  getSpace() {
    if (utils.isArray(this.space)) {
      return this.space;
    }

    utils.error(
      'ERROR: space not found.\n You must specify the objects this hook will use under the "space" attribute in the state.\n Example:\n space: ["entities", "time"]'
    );
  }

});

export default Marker;