choroplethFeature.js

var inherit = require('./inherit');
var feature = require('./feature');

/**
 * Choropleth feature specification.
 *
 * @typedef {geo.feature.spec} geo.choroplethFeature.spec
 * @extends geo.feature.spec
 * @property {geo.colorObject[]} [colorRange] Color lookup table.  Default is
 *   9-step color table.
 * @property {function} [scale] A scale converts a input domain into the
 *   the colorRange.  Default is `d3.scaleQuantize()`.
 * @property {function} [geoId] Given a geometry feature, return an identifier.
 * @property {function} [scalarId] Given a scalar element, return an
 *   identifier.
 * @property {function} [scalarValue] Given a scalar element, return a numeric
 *   values.
 */

/**
 * Create a new instance of class choroplethFeature.
 *
 * @class
 * @alias geo.choroplethFeature
 * @param {geo.choroplethFeature.spec} arg Feature specification.
 * @extends geo.feature
 * @returns {geo.choroplethFeature}
 */
var choroplethFeature = function (arg) {
  'use strict';
  if (!(this instanceof choroplethFeature)) {
    return new choroplethFeature(arg);
  }
  arg = arg || {};
  feature.call(this, arg);

  var ensureFunction = require('./util').ensureFunction;

  delete arg.layer;
  delete arg.renderer;
  /**
   * @private
   */
  var d3 = require('./svg/svgRenderer').d3,
      m_this = this,
      s_init = this._init,
      m_choropleth = Object.assign(
        {},
        {
          colorRange: [
            {r: 0.07514311, g: 0.468049805, b: 1},
            {r: 0.468487184, g: 0.588057293, b: 1},
            {r: 0.656658579, g: 0.707001303, b: 1},
            {r: 0.821573924, g: 0.837809045, b: 1},
            {r: 0.943467973, g: 0.943498599, b: 0.943398095},
            {r: 1, g: 0.788626485, b: 0.750707739},
            {r: 1, g: 0.6289553, b: 0.568237474},
            {r: 1, g: 0.472800903, b: 0.404551679},
            {r: 0.916482116, g: 0.236630659, b: 0.209939162}
          ],
          scale: d3.scaleQuantize(),
          //accessor for ID on geodata feature
          geoId: function (geoFeature) {
            return geoFeature.properties.GEO_ID;
          },
          //accessor for ID on scalar element
          scalarId: function (scalarElement) {
            return scalarElement.id;
          },
          //accessor for value on scalar element
          scalarValue: function (scalarElement) {
            return scalarElement.value;
          }
        },
        arg);

  this.featureType = 'choropleth';

  /**
   * Get/Set choropleth scalar data.
   *
   * @param {object[]} [data] An array of objects that are passed to the
   *   `scalarId` and `scalarValue` functions to get the associated information
   *   for each scalar.
   * @param {function} [aggregator] The aggregator aggregates the scalar when
   *   there are multiple values with the same id. The default is `d3.mean`.
   * @returns {object[]|this} Either the current scalar data or the feature
   *   instance.
   */
  this.scalar = function (data, aggregator) {
    var scalarId, scalarValue;

    if (data === undefined) {
      return m_this.choropleth.get('scalar')() || [];
    } else {
      scalarId = m_this.choropleth.get('scalarId');
      scalarValue = m_this.choropleth.get('scalarValue');
      m_choropleth.scalar = data;
      m_choropleth.scalarAggregator = aggregator || d3.mean;
      // we make internal dictionary from array for faster lookup
      // when matching geojson features to scalar values,
      // note that we also allow for multiple scalar elements
      // for the same geo feature
      m_choropleth.scalar._dictionary = data
        .reduce(function (accumeDictionary, scalarElement) {
          var id, value;

          id = scalarId(scalarElement);
          value = scalarValue(scalarElement);

          accumeDictionary[id] =
            accumeDictionary[id] ?
              accumeDictionary[id].push(value) : [value];

          return accumeDictionary;
        }, {});
      m_this.dataTime().modified();
    }
    return m_this;
  };

  /**
   * Get/Set choropleth accessor.
   *
   * @param {string|geo.choroplethFeature.spec} [arg1] If `undefined`,
   *    return the current choropleth specification.  If a string is specified,
   *    either get or set the named property.  If an object is given, set
   *    or update the specification with the specified parameters.
   * @param {object} [arg2] If `arg1` is a string, set that property to `arg2`.
   *    If `undefined`, return the current value of the named  property.
   * @returns {geo.choroplethFeature.spec|object|this} The current choropleth
   *    specification, the value of a named property, or this object.
   */
  this.choropleth = function (arg1, arg2) {
    var choropleth;

    if (arg1 === undefined) {
      return m_choropleth;
    }
    if (typeof arg1 === 'string' && arg2 === undefined) {
      return m_choropleth[arg1];
    }
    if (arg2 === undefined) {
      choropleth = Object.assign(
        {},
        m_choropleth,
        arg1
      );
      m_choropleth = choropleth;
    } else {
      m_choropleth[arg1] = arg2; //if you pass in accessor for prop
    }
    m_this.modified();
    return m_this;
  };

  /**
   * A uniform getter that always returns a function even for constant
   * choropleth properties.  This can also return all defined properties as
   * functions in a single object.
   *
   * @function choropleth_DOT_get
   * @memberof geo.choroplethFeature
   * @instance
   * @param {string} [key] If defined, return a function for the named
   *    property.  Otherwise, return an object with a function for all defined
   *    properties.
   * @returns {function|object} Either a function for the named property or an
   *    object with functions for all defined properties.
   */
  this.choropleth.get = function (key) {
    var all = {}, k;
    if (key === undefined) {
      for (k in m_choropleth) {
        if (m_choropleth.hasOwnProperty(k)) {
          all[k] = m_this.choropleth.get(k);
        }
      }
      return all;
    }
    return ensureFunction(m_choropleth[key]);
  };

  /**
   * Add a geojson polygon feature to the current layer.
   *
   * @param {geo.geojsonFeature} feature A geojson parsed feature.
   * @param {geo.geoColor} fillColor The fill color for the feature.
   * @returns {geo.polygonFeature}
   */
  this._featureToPolygons = function (feature, fillColor) {
    var newFeature = m_this.layer().createFeature('polygon', {});

    if (feature.geometry.type === 'Polygon') {
      newFeature.data([{
        type: 'Polygon',
        coordinates: feature.geometry.coordinates
      }]);
    } else if (feature.geometry.type === 'MultiPolygon') {
      newFeature.data(feature.geometry.coordinates.map(function (coordinateMap) {
        return {
          type: 'Polygon',
          coordinates: coordinateMap
        };
      }));
    }

    newFeature
      .polygon(function (d) {
        return {
          outer: d.coordinates[0],
          inner: d.coordinates[1] // undefined but ok
        };
      })
      .position(function (d) {
        return {
          x: d[0],
          y: d[1]
        };
      })
      .style({
        fillColor: fillColor
      });

    return newFeature;
  };

  /**
   * Set a choropleth scale's domain and range.
   *
   * @param {function} valueAccessor A function that can be passed to
   *    `d3.extent`.
   * @returns {this}
   */
  this._generateScale = function (valueAccessor) {
    var extent = d3.extent(m_this.scalar(), valueAccessor || undefined);

    m_this.choropleth()
      .scale
      .domain(extent)
      .range(m_this.choropleth().colorRange);

    return m_this;
  };

  /**
   * Generate scale for choropleth.data(), make polygons from features.
   *
   * @returns {geo.featurePolygon[]}
   */
  this.createChoropleth = function () {
    var choropleth = m_this.choropleth,
        data = m_this.data(),
        scalars = m_this.scalar(),
        valueFunc = choropleth.get('scalarValue'),
        getFeatureId = choropleth.get('geoId');

    m_this._generateScale(valueFunc);

    return data.map(function (feature) {
      var id = getFeatureId(feature);
      var valueArray = scalars._dictionary[id];
      var accumulatedScalarValue = choropleth().scalarAggregator(valueArray);
      // take average of this array of values
      // which allows for non-bijective correspondence
      // between geo data and scalar data
      var fillColor = m_this.choropleth().scale(accumulatedScalarValue);

      return m_this._featureToPolygons(feature, fillColor);
    });
  };

  /**
   * Initialize.
   *
   * @param {geo.choroplethFeature} arg
   * @returns {this}
   */
  this._init = function (arg) {
    s_init.call(m_this, arg);

    if (m_choropleth) {
      m_this.dataTime().modified();
    }
    return m_this;
  };

  this._init(arg);
  return this;
};

inherit(choroplethFeature, feature);
module.exports = choroplethFeature;