Map/AbsConcept.js

"use strict";

/*global require*/
var naturalSort = require("javascript-natural-sort");
naturalSort.insensitive = true;

var defaultValue = require("terriajs-cesium/Source/Core/defaultValue").default;
var defined = require("terriajs-cesium/Source/Core/defined").default;
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;

var AbsCode = require("./AbsCode");
var inherit = require("../Core/inherit");
var Concept = require("../Map/Concept");
/**
 * Represents an ABS concept associated with a AbsDataset.
 * An AbsConcept contains an array one of more AbsCodes.
 *
 * @alias AbsConcept
 * @constructor
 * @extends Concept
 * @param {Object} [options] Object with the following properties:
 * @param {String} [options.name] The concept's human-readable name, eg. "Region Type".
 * @param {String[]} [options.id] The concept's id (the name given to it by the ABS), eg. "REGIONTYPE".
 * @param {Object[]} [options.codes] ABS code objects describing a tree of codes under this concept,
 *        eg. [{code: '01_02', description: 'Negative/Nil Income', parentCode: 'TOT', parentDescription: 'Total'}, ...].
 * @param {String[]} [options.filter] The initial concepts and codes to activate.
 * @param {Boolean} [options.allowMultiple] Does this concept only allow multiple active children (eg. all except Region type)?
 * @param {Function} [options.activeItemsChangedCallback] A function called when activeItems changes.
 */
var AbsConcept = function(options) {
  options = defaultValue(options, defaultValue.EMPTY_OBJECT);

  Concept.call(this, options.name || options.id);

  /**
   * Gets or sets the name of the concept item.  This property is observable.
   * @type {String}
   */
  this.id = options.id;

  /**
   * Gets the list of absCodes contained in this group.  This property is observable.
   * @type {AbsConcept[]}
   */
  this.items = [];

  /**
   * Gets or sets a value indicating whether this concept item is currently open.  When an
   * item is open, its child items (if any) are visible.  This property is observable.
   * @type {Boolean}
   */
  this.isOpen = true;

  /**
   * Flag to say if this if this node is selectable.  This property is observable.
   * @type {Boolean}
   */
  this.isSelectable = false;

  /**
   * Flag to say if this if this concept only allows more than one active child. (Defaults to false.)
   * @type {Boolean}
   */
  this.allowMultiple = defaultValue(options.allowMultiple, false);

  if (defined(options.codes)) {
    var anyActive = buildConceptTree(this, options.filter, this, options.codes);
    // If no codes were made active via the 'filter' parameter, activate the first one.
    if (!anyActive && this.items.length > 0) {
      this.items[0].isActive = true;
    }
  }

  knockout.track(this, ["items", "isOpen"]); // name, isSelectable already tracked by Concept

  // Returns a flat array of all the active AbsCodes under this concept (walking the tree).
  // For this calculation, a concept may be active independently of its children.
  knockout.defineProperty(this, "activeItems", {
    get: function() {
      return getActiveChildren(this);
    }
  });

  knockout.getObservable(this, "activeItems").subscribe(function(activeItems) {
    options.activeItemsChangedCallback(this, activeItems);
  }, this);
};

inherit(Concept, AbsConcept);

Object.defineProperties(AbsConcept.prototype, {
  /**
   * Gets a value indicating whether this item has child items.
   * @type {Boolean}
   */
  hasChildren: {
    get: function() {
      return this.items.length > 0;
    }
  }
});

/**
 * Toggles the {@link AbsConcept#isOpen} property.  If this item's list of children is open,
 * calling this method will close it.  If the list is closed, calling this method will open it.
 */
AbsConcept.prototype.toggleOpen = function() {
  this.isOpen = !this.isOpen;
};

/**
 * Finds all the active children and recodes them as a 'filter', eg. ['REGION.SA2', 'MEASURE.A02']
 * @return {String[]} The active children as a filter.
 */
AbsConcept.prototype.toFilter = function() {
  var activeChildren = getActiveChildren(this);
  return activeChildren.map(function(child) {
    return child.concept.id + "." + child.code;
  });
};

function getActiveChildren(concept) {
  if (!defined(concept)) {
    return;
  }
  var result = [];
  concept.items.forEach(function(child) {
    if (child.isActive) {
      result.push(child);
    }
    result = result.concat(getActiveChildren(child));
  });
  return result;
}

// Recursively builds out the AbsCodes underneath this AbsConcept.
// Returns true if any codes were made active, false if none.
function buildConceptTree(parent, filter, concept, codes) {
  // Use natural sort for fields with included ages or incomes.
  codes.sort(function(a, b) {
    return naturalSort(
      a.description.replace(",", ""),
      b.description.replace(",", "")
    );
  });
  var anyActive = false;
  for (var i = 0; i < codes.length; ++i) {
    var parentCode = parent instanceof AbsCode ? parent.code : "";
    if (codes[i].parentCode === parentCode) {
      var absCode = new AbsCode(codes[i].code, codes[i].description, concept);
      var codeFilter = concept.id + "." + absCode.code;
      if (defined(filter) && filter.indexOf(codeFilter) !== -1) {
        absCode.isActive = true;
        anyActive = true;
      }
      if (parentCode === "" && codes.length < 50) {
        absCode.isOpen = true;
      }
      absCode.parent = parent;
      parent.items.push(absCode);
      var anyChildrenActive = buildConceptTree(absCode, filter, concept, codes);
      anyActive = anyChildrenActive || anyActive;
    }
  }
  return anyActive;
}

module.exports = AbsConcept;