svg/svgRenderer.js

var inherit = require('../inherit');
var registerRenderer = require('../registry').registerRenderer;
var renderer = require('../renderer');

/**
 * Create a new instance of class svgRenderer.
 *
 * @class
 * @alias geo.svg.renderer
 * @extends geo.renderer
 * @param {object} arg Options for the renderer.
 * @param {geo.layer} [arg.layer] Layer associated with the renderer.
 * @param {HTMLElement} [arg.canvas] Canvas element associated with the
 *   renderer.
 * @param {boolean} [arg.widget=false] Set to `true` if this is a stand-alone
 *   widget.  If it is not a widget, svg elements are wrapped in a parent
 *   group.
 * @param {HTMLElement} [arg.d3Parent] If specified, the parent for any
 *   rendered objects; otherwise the renderer's layer's main node is used.
 * @returns {geo.svg.svgRenderer}
 */
var svgRenderer = function (arg) {
  'use strict';

  var d3 = svgRenderer.d3;
  var object = require('./object');
  var util = require('../util');
  var geo_event = require('../event');
  var svgRescale = require('./rescale');

  if (!(this instanceof svgRenderer)) {
    return new svgRenderer(arg);
  }
  renderer.call(this, arg);

  var s_exit = this._exit;

  object.call(this, arg);

  arg = arg || {};

  var m_this = this,
      m_sticky = null,
      m_features = {},
      m_corners = null,
      m_diagonal = null,
      m_scale = 1,
      m_transform = {dx: 0, dy: 0, rx: 0, ry: 0, rotation: 0},
      m_renderIds = {},
      m_removeIds = {},
      m_svg = null,
      m_defs = null;

  /**
   * Set attributes to a d3 selection.
   *
   * @param {d3Selector} select The d3 selector with the elements to change.
   * @param {object} attrs A map of attributes to set on the elements.
   */
  function setAttrs(select, attrs) {
    var key;
    for (key in attrs) {
      if (attrs.hasOwnProperty(key)) {
        select.attr(key, attrs[key]);
      }
    }
  }

  /**
   * Meta functions for converting from geojs styles to d3.
   *
   * @param {object|function} f The style value or function to convert.
   * @param {function} [g] An optional function that returns a boolean; if it
   *    returns false, the style is set to `'none'`.
   * @returns {function} A function for converting styles.
   */
  this._convertColor = function (f, g) {
    f = util.ensureFunction(f);
    g = g || function () { return true; };
    return function () {
      var c = 'none';
      if (g.apply(m_this, arguments)) {
        c = f.apply(m_this, arguments);
        if (c.hasOwnProperty('r') &&
            c.hasOwnProperty('g') &&
            c.hasOwnProperty('b')) {
          c = d3.rgb(255 * c.r, 255 * c.g, 255 * c.b);
        }
      }
      return c;
    };
  };

  /**
   * Return a function for converting a size in pixels to an appropriate
   * d3 scale.
   *
   * @param {object|function} f The style value or function to convert.
   * @returns {function} A function for converting scale.
   */
  this._convertScale = function (f) {
    f = util.ensureFunction(f);
    return function () {
      return f.apply(m_this, arguments) / m_scale;
    };
  };

  /**
   * Set styles to a d3 selection. Ignores unknown style keys.
   *
   * @param {d3Selector} select The d3 selector with the elements to change.
   * @param {object} styles Style object associated with a feature.
   */
  function setStyles(select, styles) {
    var key, k, f;
    /**
     * Check if the fill parameter is truthy.
     *
     * @returns {null|'none'} `null` to fill the element, `'none'` to skip
     *  filling it.
     */
    function fillFunc() {
      if (styles.fill.apply(m_this, arguments)) {
        return null;
      } else {
        return 'none';
      }
    }
    /**
     * Check if the stroke parameter is truthy.
     *
     * @returns {null|'none'} `null` to fill the element, `'none'` to skip
     *  filling it.
     */
    function strokeFunc() {
      if (styles.stroke.apply(m_this, arguments)) {
        return null;
      } else {
        return 'none';
      }
    }
    for (key in styles) {
      if (styles.hasOwnProperty(key)) {
        f = null;
        k = null;
        if (key === 'strokeColor') {
          k = 'stroke';
          f = m_this._convertColor(styles[key], styles.stroke);
        } else if (key === 'stroke' && styles[key] &&
                   !styles.hasOwnProperty('strokeColor')) {
          k = 'stroke';
          f = strokeFunc;
        } else if (key === 'strokeWidth') {
          k = 'stroke-width';
          f = m_this._convertScale(styles[key]);
        } else if (key === 'strokeOpacity') {
          k = 'stroke-opacity';
          f = styles[key];
        } else if (key === 'fillColor') {
          k = 'fill';
          f = m_this._convertColor(styles[key], styles.fill);
        } else if (key === 'fill' && !styles.hasOwnProperty('fillColor')) {
          k = 'fill';
          f = fillFunc;
        } else if (key === 'fillOpacity') {
          k = 'fill-opacity';
          f = styles[key];
        } else if (key === 'lineCap') {
          k = 'stroke-linecap';
          f = styles[key];
        } else if (key === 'lineJoin') {
          k = 'stroke-linejoin';
          f = styles[key];
        } else if (key === 'miterLimit') {
          k = 'stroke-miterlimit';
          f = styles[key];
        }
        if (k) {
          select.style(k, f);
        }
      }
    }
  }

  /**
   * Get the svg group element associated with this renderer instance, or of a
   * group within the render instance.
   *
   * @param {string} [parentId] Optional parent ID name.
   * @returns {d3Selector} Selector with the d3 group.
   */
  function getGroup(parentId) {
    if (parentId) {
      return m_svg.select('.group-' + parentId);
    }
    return m_svg.select('.group-' + m_this._svgid());
  }

  /**
   * Set the initial lat-lon coordinates of the map view.
   */
  function initCorners() {
    var layer = m_this.layer(),
        map = layer.map(),
        width = map.size().width,
        height = map.size().height;

    if (!width || !height) {
      throw new Error('Map layer has size 0');
    }
    m_this._setWidthHeight(width, height);
    m_diagonal = Math.pow(width * width + height * height, 0.5);
    m_corners = {
      upperLeft: map.displayToGcs({x: 0, y: 0}, null),
      lowerRight: map.displayToGcs({x: width, y: height}, null),
      center: map.displayToGcs({x: width / 2, y: height / 2}, null)
    };
  }

  /**
   * Set the translation, scale, and zoom for the current view.
   * @private
   */
  this._setTransform = function () {
    if (!m_corners) {
      initCorners();
    }

    if (!m_sticky) {
      return;
    }

    var layer = m_this.layer();

    var map = layer.map(),
        upperLeft = map.gcsToDisplay(m_corners.upperLeft, null),
        lowerRight = map.gcsToDisplay(m_corners.lowerRight, null),
        center = map.gcsToDisplay(m_corners.center, null),
        group = getGroup(),
        dx, dy, scale, rotation, rx, ry;

    scale = Math.sqrt(
      Math.pow(lowerRight.y - upperLeft.y, 2) +
      Math.pow(lowerRight.x - upperLeft.x, 2)) / m_diagonal;
    // calculate the translation
    rotation = map.rotation();
    rx = -m_this.width() / 2;
    ry = -m_this.height() / 2;
    dx = scale * rx + center.x;
    dy = scale * ry + center.y;

    // set the group transform property
    if (!rotation) {
      dx = Math.round(dx);
      dy = Math.round(dy);
    }
    var transform = 'matrix(' + [scale, 0, 0, scale, dx, dy].join() + ')';
    if (rotation) {
      transform += ' rotate(' + [
        rotation * 180 / Math.PI, -rx, -ry].join() + ')';
    }
    group.attr('transform', transform);

    // set internal variables
    m_scale = scale;
    m_transform.dx = dx;
    m_transform.dy = dy;
    m_transform.rx = rx;
    m_transform.ry = ry;
    m_transform.rotation = rotation;
  };

  /**
   * Convert from screen pixel coordinates to the local coordinate system
   * in the SVG group element taking into account the transform.
   *
   * @private
   * @param {geo.screenPosition} pt The coordinates to convert.
   * @returns {geo.geoPosition} The converted coordinates.
   */
  this.baseToLocal = function (pt) {
    pt = {
      x: (pt.x - m_transform.dx) / m_scale,
      y: (pt.y - m_transform.dy) / m_scale
    };
    if (m_transform.rotation) {
      var sinr = Math.sin(-m_transform.rotation),
          cosr = Math.cos(-m_transform.rotation);
      var x = pt.x + m_transform.rx, y = pt.y + m_transform.ry;
      pt = {
        x: x * cosr - y * sinr - m_transform.rx,
        y: x * sinr + y * cosr - m_transform.ry
      };
    }
    return pt;
  };

  /**
   * Convert from the local coordinate system in the SVG group element
   * to screen pixel coordinates.
   *
   * @private
   * @param {geo.geoPosition} pt The coordinates to convert.
   * @returns {geo.screenPosition} The converted coordinates.
   */
  this.localToBase = function (pt) {
    if (m_transform.rotation) {
      var sinr = Math.sin(m_transform.rotation),
          cosr = Math.cos(m_transform.rotation);
      var x = pt.x + m_transform.rx, y = pt.y + m_transform.ry;
      pt = {
        x: x * cosr - y * sinr - m_transform.rx,
        y: x * sinr + y * cosr - m_transform.ry
      };
    }
    pt = {
      x: pt.x * m_scale + m_transform.dx,
      y: pt.y * m_scale + m_transform.dy
    };
    return pt;
  };

  /**
   * Initialize.
   *
   * @param {object} arg The options used to create the renderer.
   * @param {boolean} [arg.widget=false] Set to `true` if this is a stand-alone
   *   widget.  If it is not a widget, svg elements are wrapped in a parent
   *   group.
   * @param {HTMLElement} [arg.d3Parent] If specified, the parent for any
   *   rendered objects; otherwise the renderer's layer's main node is used.
   * @returns {this}
   */
  this._init = function (arg) {
    if (!m_this.canvas()) {
      var canvas;
      arg.widget = arg.widget || false;

      if ('d3Parent' in arg) {
        m_svg = d3.select(arg.d3Parent).append('svg');
      } else {
        m_svg = d3.select(m_this.layer().node().get(0)).append('svg');
      }
      m_svg.attr('display', 'block');

      // create a global svg definitions element
      m_defs = m_svg.append('defs');

      var shadow = m_defs
        .append('filter')
          .attr('id', 'geo-highlight')
          .attr('x', '-100%')
          .attr('y', '-100%')
          .attr('width', '300%')
          .attr('height', '300%');
      shadow
        .append('feMorphology')
          .attr('operator', 'dilate')
          .attr('radius', 2)
          .attr('in', 'SourceAlpha')
          .attr('result', 'dilateOut');
      shadow
        .append('feGaussianBlur')
          .attr('stdDeviation', 5)
          .attr('in', 'dilateOut')
          .attr('result', 'blurOut');
      shadow
        .append('feColorMatrix')
          .attr('type', 'matrix')
          .attr('values', '-1 0 0 0 1  0 -1 0 0 1  0 0 -1 0 1  0 0 0 1 0')
          .attr('in', 'blurOut')
          .attr('result', 'invertOut');
      shadow
        .append('feBlend')
          .attr('in', 'SourceGraphic')
          .attr('in2', 'invertOut')
          .attr('mode', 'normal');

      if (!arg.widget) {
        canvas = arg.canvas || m_svg.append('g');
      }

      shadow = m_defs.append('filter')
          .attr('id', 'geo-blur')
          .attr('x', '-100%')
          .attr('y', '-100%')
          .attr('width', '300%')
          .attr('height', '300%');

      shadow
        .append('feGaussianBlur')
          .attr('stdDeviation', 20)
          .attr('in', 'SourceGraphic');

      m_sticky = m_this.layer().sticky();
      m_svg.attr('class', m_this._svgid());
      m_svg.attr('width', m_this.layer().node().width());
      m_svg.attr('height', m_this.layer().node().height());

      if (!arg.widget) {
        canvas.attr('class', 'group-' + m_this._svgid());

        m_this.canvas(canvas);
      } else {
        m_this.canvas(m_svg);
      }
    }
    m_this._setTransform();
    return m_this;
  };

  /**
   * Get API used by the renderer.
   *
   * @returns {string} 'svg'.
   */
  this.api = function () {
    return svgRenderer.apiname;
  };

  /**
   * Return the current scaling factor to build features that shouldn't
   * change size during zooms.  For example:
   *
   *  selection.append('circle')
   *    .attr('r', r0 / renderer.scaleFactor());
   *
   * This will create a circle element with radius r0 independent of the
   * current zoom level.
   *
   * @returns {number} The current scale factor.
   */
  this.scaleFactor = function () {
    return m_scale;
  };

  /**
   * Handle resize event.
   *
   * @param {number} x Ignored.
   * @param {number} y Ignored.
   * @param {number} w New width in pixels.
   * @param {number} h New height in pixels.
   * @returns {this}
   * @fires geo.event.svg.rescale
   */
  this._resize = function (x, y, w, h) {
    if (!m_corners) {
      initCorners();
    }
    m_svg.attr('width', w);
    m_svg.attr('height', h);
    m_this._setTransform();
    m_this._setWidthHeight(w, h);
    m_this.layer().geoTrigger(svgRescale, { scale: m_scale }, true);
    return m_this;
  };

  /**
   * Exit.
   */
  this._exit = function () {
    m_features = {};
    m_this.canvas().remove();
    m_svg.remove();
    m_svg = undefined;
    m_defs.remove();
    m_defs = undefined;
    m_renderIds = {};
    m_removeIds = {};
    s_exit();
  };

  /**
   * Get the definitions DOM element for the layer.
   * @protected
   * @returns {HTMLElement} The definitions DOM element.
   */
  this._definitions = function () {
    return m_defs;
  };

  /**
   * Create a new feature element from an object that describes the feature
   * attributes.  To be called from feature classes only.
   *
   * @param {object} arg
   * @param {string} arg.id A unique string identifying the feature.
   * @param {array} arg.data Array of data objects used in a d3 data method.
   * @param {function} [arg.dataIndex] A function that returns a unique id for
   *    each data element.  This is passed to the data access function.
   * @param {object} arg.style An object with style values or functions.
   * @param {object} arg.attributes An object containing element attributes.
   *    The keys are the attribute names, and the values are either constants
   *    or functions that get passed a data element and a data index.
   * @param {string[]} arg.classes An array of classes to add to the elements.
   * @param {string} arg.append The element type as used in d3 append methods.
   *    This is something like `'path'`, `'circle'`, or `'line'`.
   * @param {boolean} [arg.onlyRenderNew] If truthy, features only get
   *    attributes and styles set when new.  If falsy, features always have
   *    attributes and styles updated.
   * @param {boolean} [arg.sortByZ] If truthy, sort features by the `d.zIndex`.
   * @param {string} [arg.parentId] If set, the group ID of the parent element.
   * @returns {this}
   */
  this._drawFeatures = function (arg) {
    m_features[arg.id] = {
      data: arg.data,
      index: arg.dataIndex,
      style: arg.style,
      visible: arg.visible,
      attributes: arg.attributes,
      classes: arg.classes,
      append: arg.append,
      onlyRenderNew: arg.onlyRenderNew,
      sortByZ: arg.sortByZ,
      parentId: arg.parentId
    };
    return m_this.__render(arg.id, arg.parentId);
  };

  /**
   * Updates a feature by performing a d3 data join.  If no input id is
   * provided then this method will update all features.
   *
   * @param {string} [id] The id of the feature to update.  `undefined` to
   *    update all features.
   * @param {string} [parentId] The parent of the feature(s).  If not
   *    specified, features are rendered on the next animation frame.
   * @returns {this}
   */
  this.__render = function (id, parentId) {
    var key;
    if (id === undefined) {
      for (key in m_features) {
        if (m_features.hasOwnProperty(key)) {
          m_this.__render(key);
        }
      }
      return m_this;
    }
    if (parentId) {
      m_this._renderFeature(id, parentId);
    } else {
      m_renderIds[id] = true;
      m_this.layer().map().scheduleAnimationFrame(m_this._renderFrame);
    }
    return m_this;
  };

  /**
   * Render all features that are marked as needing an update.  This should
   * only be called duration an animation frame.
   */
  this._renderFrame = function () {
    var id;
    for (id in m_removeIds) {
      m_this.select(id).remove();
      m_defs.selectAll('.' + id).remove();
    }
    m_removeIds = {};
    var ids = m_renderIds;
    m_renderIds = {};
    for (id in ids) {
      if (ids.hasOwnProperty(id)) {
        m_this._renderFeature(id);
      }
    }
  };

  /**
   * Render a single feature.
   *
   * @param {string} id The id of the feature to update.
   * @param {string} [parentId] The parent of the feature.  This is used to
   *    select the feature.
   * @returns {this}
   */
  this._renderFeature = function (id, parentId) {
    if (!m_features[id]) {
      return m_this;
    }
    var data = m_features[id].data,
        index = m_features[id].index,
        style = m_features[id].style,
        visible = m_features[id].visible,
        attributes = m_features[id].attributes,
        classes = m_features[id].classes,
        append = m_features[id].append,
        selection = m_this.select(id, parentId).data(data, index),
        entries, rendersel;
    entries = selection.enter().append(append);
    selection.exit().remove();
    // in d3 v3 this was
    // rendersel = m_features[id].onlyRenderNew ? entries : selection;
    rendersel = entries;
    setAttrs(rendersel, attributes);
    rendersel.attr('class', classes.concat([id]).join(' '));
    setStyles(rendersel, style);
    if (visible) {
      rendersel.style('visibility', visible() ? 'visible' : 'hidden');
    }
    if (entries.size() && m_features[id].sortByZ) {
      selection.sort(function (a, b) {
        return (a.zIndex || 0) - (b.zIndex || 0);
      });
    }
    return m_this;
  };

  /**
   * Returns a d3 selection for the given feature id.
   *
   * @param {string} id The id of the feature to select.
   * @param {string} [parentId] The parent of the feature.  This is used to
   *    determine the feature's group.
   * @returns {d3Selector}
   */
  this.select = function (id, parentId) {
    return getGroup(parentId).selectAll('.' + id);
  };

  /**
   * Removes a feature from the layer.
   *
   * @param {string} id The id of the feature to remove.
   * @returns {this}
   */
  this._removeFeature = function (id) {
    m_removeIds[id] = true;
    m_this.layer().map().scheduleAnimationFrame(m_this._renderFrame);
    delete m_features[id];
    if (m_renderIds[id]) {
      delete m_renderIds[id];
    }
    return m_this;
  };

  /**
   * Override draw method to do nothing.
   */
  this.draw = function () {
  };

  // connect to pan event
  this.layer().geoOn(geo_event.pan, m_this._setTransform);

  // connect to rotate event
  this.layer().geoOn(geo_event.rotate, m_this._setTransform);

  // connect to zoom event
  this.layer().geoOn(geo_event.zoom, function () {
    m_this._setTransform();
    m_this.__render();
    m_this.layer().geoTrigger(svgRescale, { scale: m_scale }, true);
  });

  this.layer().geoOn(geo_event.resize, function (event) {
    m_this._resize(event.x, event.y, event.width, event.height);
  });

  this._init(arg);
  return this;
};
svgRenderer.apiname = 'svg';

inherit(svgRenderer, renderer);

registerRenderer('svg', svgRenderer);
// Also register under an alternate name (alias for backwards compatibility)
registerRenderer('d3', svgRenderer);

/* Code for checking if the renderer is supported */

/**
 * Report if the d3 renderer is supported.  This is just a check if d3 is
 * available.
 *
 * @returns {boolean} true if available.
 */
svgRenderer.supported = function () {
  delete svgRenderer.d3;
  // webpack expects optional dependencies to be wrapped in a try-catch
  try {
    svgRenderer.d3 = require('d3');
    if (!svgRenderer.d3 || !svgRenderer.d3.rgb) {
      svgRenderer.d3 = undefined;
    }
  } catch (_error) {}
  return svgRenderer.d3 !== undefined;
};

/**
 * If the d3 renderer is not supported, supply the name of a renderer that
 * should be used instead.  This asks for the null renderer.
 *
 * @returns {null} `null` for the null renderer.
 */
svgRenderer.fallback = function () {
  return null;
};

svgRenderer.supported();  // cache reference to d3 if it is available

module.exports = svgRenderer;