canvas/textFeature.js

var inherit = require('../inherit');
var registerFeature = require('../registry').registerFeature;
var textFeature = require('../textFeature');
var util = require('../util');
var mat3 = require('gl-mat3');
var vec3 = require('gl-vec3');

/**
 * Create a new instance of class canvas.textFeature.
 *
 * @class
 * @alias geo.canvas.textFeature
 * @extends geo.textFeature
 * @extends geo.canvas.object
 *
 * @param {geo.textFeature.spec} [arg] Options for the feature.
 * @returns {geo.canvas.textFeature} The created feature.
 */
var canvas_textFeature = function (arg) {
  'use strict';
  if (!(this instanceof canvas_textFeature)) {
    return new canvas_textFeature(arg);
  }

  var object = require('./object');

  arg = arg || {};
  textFeature.call(this, arg);
  object.call(this);

  /**
   * @private
   */
  var m_this = this,
      m_defaultFont = 'bold 16px sans-serif',
      /* This regexp parses css font specifications into style, variant,
       * weight, stretch, size, line height, and family.  It is based on a
       * regexp here: https://stackoverflow.com/questions/10135697/regex-to-parse-any-css-font,
       * but has been modified to fix some issues and handle font stretch. */
      m_cssFontRegExp = new RegExp(
        '^\\s*' +
        '(?=(?:(?:[-a-z0-9]+\\s+){0,3}(italic|oblique))?)' +
        '(?=(?:(?:[-a-z0-9]+\\s+){0,3}(small-caps))?)' +
        '(?=(?:(?:[-a-z0-9]+\\s+){0,3}(bold(?:er)?|lighter|[1-9]00))?)' +
        '(?=(?:(?:[-a-z0-9]+\\s+){0,3}((?:ultra-|extra-|semi-)?(?:condensed|expanded)))?)' +
        '(?:(?:normal|\\1|\\2|\\3|\\4)\\s+){0,4}' +
        '((?:xx?-)?(?:small|large)|medium|smaller|larger|[.\\d]+(?:\\%|in|[cem]m|ex|p[ctx]))' +
        '(?:/(normal|[.\\d]+(?:\\%|in|[cem]m|ex|p[ctx])))?\\s+' +
        '([-,\\"\\sa-z]+?)\\s*$', 'i');

  /**
   * Get the font for a specific data item.  This falls back to the default
   * font if the value is unset or doesn't contain sufficient information.
   *
   * @param {boolean} useSubValues If truthy, check all font styles (such as
   *    `fontSize`, `lineHeight`, etc., and override the code `font` style
   *    with those values.  If falsy, only use `font`.
   * @param {object} d The current data element.
   * @param {number} i The index of the current data element.
   * @returns {string} The font style.
   */
  this.getFontFromStyles = function (useSubValues, d, i) {
    var font = m_this.style.get('font')(d, i) || m_defaultFont;
    if (useSubValues) {
      var parts = m_cssFontRegExp.exec(font);
      if (parts === null) {
        parts = m_cssFontRegExp.exec(m_defaultFont);
      }
      parts[1] = m_this.style.get('fontStyle')(d, i) || parts[1];
      parts[2] = m_this.style.get('fontVariant')(d, i) || parts[2];
      parts[3] = m_this.style.get('fontWeight')(d, i) || parts[3];
      parts[4] = m_this.style.get('fontStretch')(d, i) || parts[4];
      parts[5] = m_this.style.get('fontSize')(d, i) || parts[5] || '16px';
      parts[6] = m_this.style.get('lineHeight')(d, i) || parts[6];
      parts[7] = m_this.style.get('fontFamily')(d, i) || parts[7] || 'sans-serif';
      font = (parts[1] || '') + ' ' + (parts[2] || '') + ' ' +
             (parts[3] || '') + ' ' + (parts[4] || '') + ' ' +
             (parts[5] || '') + (parts[6] ? '/' + parts[6] : '') + ' ' +
             parts[7];
      font = font.trim().replace(/\s\s+/g, ' ');
    }
    return font;
  };

  /**
   * Render the data on the canvas.
   *
   * This does not currently support multiline text or word wrapping, since
   * canvas doesn't implement that directly.  To support these, each text item
   * would need to be split on line breaks, and have the width of the text
   * calculated with context2d.measureText to determine word wrapping.  This
   * would also need to calculate the effective line height from the font
   * specification.
   *
   * @protected
   * @param {CanvasRenderingContext2D} context2d The canvas context to draw in.
   * @param {geo.map} map The parent map object.
   */
  this._renderOnCanvas = function (context2d, map) {
    var data = m_this.data(),
        posFunc = m_this.style.get('position'),
        renderThreshold = m_this.style.get('renderThreshold')(data),
        textFunc = m_this.style.get('text'),
        mapRotation = map.rotation(),
        mapZoom = map.zoom(),
        mapSize = map.size(),
        fontFromSubValues, text, posArray, pos, visible, color, blur, stroke,
        width, rotation, rotateWithMap, scale, offset,
        transform, lastTransform = util.mat3AsArray();

    /* If any of the font styles other than `font` have values, then we need to
     * construct a single font value from the subvalues.  Otherwise, we can
     * skip it. */
    fontFromSubValues = [
      'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize',
      'lineHeight', 'fontFamily'
    ].some(function (key) {
      return m_this.style(key) !== null && m_this.style(key) !== undefined;
    });
    /* Clear the canvas property buffer */
    m_this._canvasProperty();
    posArray = m_this.featureGcsToDisplay(data.map(posFunc));
    data.forEach(function (d, i) {
      /* If the position is far enough outside of the map viewport, don't
       * render it, even if the offset of size would be sufficient to make it
       * appear in the viewport. */
      pos = posArray[i];
      if (renderThreshold > 0 && (
        pos.x < -renderThreshold || pos.x > mapSize.width + renderThreshold ||
        pos.y < -renderThreshold || pos.y > mapSize.height + renderThreshold)) {
        return;
      }
      visible = m_this.style.get('visible')(d, i);
      if (!visible && visible !== undefined) {
        return;
      }
      color = util.convertColorAndOpacity(
        m_this.style.get('color')(d, i), m_this.style.get('textOpacity')(d, i));
      stroke = util.convertColorAndOpacity(
        m_this.style.get('textStrokeColor')(d, i), m_this.style.get('textOpacity')(d, i), {r: 0, g: 0, b: 0, a: 0});
      if (color.a === 0 && stroke.a === 0) {
        return;
      }
      m_this._canvasProperty(context2d, 'fillStyle', util.convertColorToRGBA(color));
      text = textFunc(d, i);
      if (text === undefined || text === null || text === '') {
        return;
      }
      m_this._canvasProperty(context2d, 'font', m_this.getFontFromStyles(fontFromSubValues, d, i));
      m_this._canvasProperty(context2d, 'textAlign', m_this.style.get('textAlign')(d, i) || 'center');
      m_this._canvasProperty(context2d, 'textBaseline', m_this.style.get('textBaseline')(d, i) || 'middle');
      /* rotation, scale, and offset */
      rotation = m_this.style.get('rotation')(d, i) || 0;
      rotateWithMap = m_this.style.get('rotateWithMap')(d, i) && mapRotation;
      scale = m_this.style.get('textScaled')(d, i);
      scale = util.isNonNullFinite(scale) ? Math.pow(2, mapZoom - scale) : null;
      offset = m_this.style.get('offset')(d, i);
      transform = util.mat3AsArray();
      if (rotation || rotateWithMap || (scale && scale !== 1) || (offset && (offset.x || offset.y))) {
        mat3.translate(transform, transform, [pos.x, pos.y]);
        if (rotateWithMap && mapRotation) {
          mat3.rotate(transform, transform, mapRotation);
        }
        mat3.translate(transform, transform, [
          offset && offset.x ? +offset.x : 0,
          offset && offset.y ? +offset.y : 0]);
        if (rotation) {
          mat3.rotate(transform, transform, rotation);
        }
        if (scale && scale !== 1) {
          mat3.scale(transform, transform, [scale, scale]);
        }
        mat3.translate(transform, transform, [-pos.x, -pos.y]);
      }
      if (lastTransform[0] !== transform[0] || lastTransform[1] !== transform[1] ||
          lastTransform[3] !== transform[3] || lastTransform[4] !== transform[4] ||
          lastTransform[6] !== transform[6] || lastTransform[7] !== transform[7]) {
        context2d.setTransform(transform[0], transform[1], transform[3], transform[4], transform[6], transform[7]);
        mat3.copy(lastTransform, transform);
      }
      /* shadow */
      color = util.convertColorAndOpacity(
        m_this.style.get('shadowColor')(d, i), undefined, {r: 0, g: 0, b: 0, a: 0});
      if (color.a) {
        offset = m_this.style.get('shadowOffset')(d, i);
        blur = m_this.style.get('shadowBlur')(d, i);
      }
      if (color.a && ((offset && (offset.x || offset.y)) || blur)) {
        m_this._canvasProperty(context2d, 'shadowColor', util.convertColorToRGBA(color));
        if (offset && (rotation || rotateWithMap) && m_this.style.get('shadowRotate')(d, i)) {
          transform = [+offset.x, +offset.y, 0];
          vec3.rotateZ(transform, transform, [0, 0, 0],
                       rotation + (rotateWithMap ? mapRotation : 0));
          offset = {x: transform[0], y: transform[1]};
        }
        m_this._canvasProperty(context2d, 'shadowOffsetX', offset && offset.x ? +offset.x : 0);
        m_this._canvasProperty(context2d, 'shadowOffsetY', offset && offset.y ? +offset.y : 0);
        m_this._canvasProperty(context2d, 'shadowBlur', blur || 0);
      } else {
        m_this._canvasProperty(context2d, 'shadowColor', 'rgba(0,0,0,0)');
      }
      /* draw the text */
      if (stroke.a) {
        width = m_this.style.get('textStrokeWidth')(d, i);
        if (isFinite(width) && width > 0) {
          m_this._canvasProperty(context2d, 'strokeStyle', util.convertColorToRGBA(stroke));
          m_this._canvasProperty(context2d, 'lineWidth', width);
          context2d.strokeText(text, pos.x, pos.y);
          m_this._canvasProperty(context2d, 'shadowColor', 'rgba(0,0,0,0)');
        }
      }
      context2d.fillText(text, pos.x, pos.y);
    });
    m_this._canvasProperty(context2d, 'globalAlpha', 1);
    context2d.setTransform(1, 0, 0, 1, 0, 0);
  };

  return this;
};

inherit(canvas_textFeature, textFeature);

// Now register it
var capabilities = {};

registerFeature('canvas', 'text', canvas_textFeature, capabilities);

module.exports = canvas_textFeature;