(function(factory, window) {
  // module loaders support for LeafletJS plugins, see:
  // https://github.com/Leaflet/Leaflet/blob/master/PLUGIN-GUIDE.md#module-loaders

  // AMD module that relies on "leaflet"
  if (typeof define === 'function' && define.amd) {
    define(['leaflet'], factory);

    // Common JS module that relies on "leaflet"
  } else if (typeof exports === 'object') {
    module.exports = factory(require('leaflet'));
  }

  // attach plugin to the global leaflet "L" variable
  if (typeof window !== 'undefined' && window.L) {
    window.L.CanvasFlowmapLayer = factory(L);

    window.L.canvasFlowmapLayer = function(originAndDestinationGeoJsonPoints, opts) {
      return new window.L.CanvasFlowmapLayer(originAndDestinationGeoJsonPoints, opts);
    };
  }
}(function(L) {
  // layer source code
  var canvasRenderer = L.canvas();

  var CanvasFlowmapLayer = L.GeoJSON.extend({
    options: {
      // this is only a default option example,
      // developers will most likely need to provide this
      // options object with values unique to their data
      originAndDestinationFieldIds: {
        originUniqueIdField: 'origin_id',
        originGeometry: {
          x: 'origin_lon',
          y: 'origin_lat'
        },
        destinationUniqueIdField: 'destination_id',
        destinationGeometry: {
          x: 'destination_lon',
          y: 'destination_lat'
        }
      },

      canvasBezierStyle: {
        type: 'simple',
        symbol: {
          // use canvas styling options (compare to CircleMarker styling below)
          strokeStyle: 'rgba(255, 0, 51, 0.8)',
          lineWidth: 0.75,
          lineCap: 'round',
          shadowColor: 'rgb(255, 0, 51)',
          shadowBlur: 1.5
        }
      },

      animatedCanvasBezierStyle: {
        type: 'simple',
        symbol: {
          // use canvas styling options (compare to CircleMarker styling below)
          strokeStyle: 'rgb(255, 46, 88)',
          lineWidth: 1.25,
          lineDashOffsetSize: 4, // custom property used with animation sprite sizes
          lineCap: 'round',
          shadowColor: 'rgb(255, 0, 51)',
          shadowBlur: 2
        }
      },

      // valid values: 'selection' or 'all'
      // use 'all' to display all Bezier paths immediately
      // use 'selection' if Bezier paths will be drawn with user interactions
      pathDisplayMode: 'all',

      wrapAroundCanvas: true,

      animationStarted: false,

      animationEasingFamily: 'Cubic',

      animationEasingType: 'In',

      animationDuration: 2000,

      pointToLayer: function(geoJsonPoint, latlng) {
        return L.circleMarker(latlng);
      },

      style: function(geoJsonFeature) {
        // use leaflet's path styling options

        // since the GeoJSON feature properties are modified by the layer,
        // developers can rely on the "isOrigin" property to set different
        // symbols for origin vs destination CircleMarker stylings

        if (geoJsonFeature.properties.isOrigin) {
          return {
            renderer: canvasRenderer, // recommended to use L.canvas()
            radius: 5,
            weight: 1,
            color: 'rgb(195, 255, 62)',
            fillColor: 'rgba(195, 255, 62, 0.6)',
            fillOpacity: 0.6
          };
        } else {
          return {
            renderer: canvasRenderer,
            radius: 2.5,
            weight: 0.25,
            color: 'rgb(17, 142, 170)',
            fillColor: 'rgb(17, 142, 170)',
            fillOpacity: 0.7
          };
        }
      }
    },

    _customCanvases: [],

    initialize: function(geoJson, options) {
      // same as L.GeoJSON intialize method, but first performs custom GeoJSON
      // data parsing and reformatting before finally calling L.GeoJSON addData method
      L.setOptions(this, options);

      this._animationPropertiesStatic = {
        offset: 0,
        resetOffset: 200,
        repeat: Infinity,
        yoyo: false
      };

      this._animationPropertiesDynamic = {
        duration: null,
        easingInfo: null
      };

      this._layers = {};

      // beginning of customized initialize method
      if (geoJson && this.options.originAndDestinationFieldIds) {
        this.setOriginAndDestinationGeoJsonPoints(geoJson);
      }

      // establish animation properties using Tween.js library
      // currently requires the developer to add it to their own app index.html
      // TODO: find better way to wrap it up in this layer source code
      if (window.hasOwnProperty('TWEEN')) {
        // set this._animationPropertiesDynamic.duration value
        this.setAnimationDuration(this.options.animationDuration);
        // set this._animationPropertiesDynamic.easingInfo value
        this.setAnimationEasing(this.options.animationEasingFamily, this.options.animationEasingType);

        // initiate the active animation tween
        this._animationTween = new TWEEN.Tween(this._animationPropertiesStatic)
          .to({
            offset: this._animationPropertiesStatic.resetOffset
          }, this._animationPropertiesDynamic.duration)
          .easing(this._animationPropertiesDynamic.easingInfo.tweenEasingFunction)
          .repeat(this._animationPropertiesStatic.repeat)
          .yoyo(this._animationPropertiesStatic.yoyo)
          .start();
      } else {
        // Tween.js lib isn't available,
        // ensure that animations aren't attempted at the beginning
        this.options.animationStarted = false;
      }
    },

    setOriginAndDestinationGeoJsonPoints: function(geoJsonFeatureCollection) {
      if (geoJsonFeatureCollection.features) {
        var configOriginGeometryObject = this.options.originAndDestinationFieldIds.originGeometry;
        var configDestinationGeometryObject = this.options.originAndDestinationFieldIds.destinationGeometry;

        geoJsonFeatureCollection.features.forEach(function(feature, index) {
          if (feature.type === 'Feature' && feature.geometry && feature.geometry.type === 'Point') {
            // origin feature -- modify attribute properties and geometry
            feature.properties.isOrigin = true;
            feature.properties._isSelectedForPathDisplay = this.options.pathDisplayMode === 'all' ? true : false;
            feature.properties._uniqueId = index + '_origin';

            feature.geometry.coordinates = [
              feature.properties[configOriginGeometryObject.x],
              feature.properties[configOriginGeometryObject.y]
            ];

            // destination feature -- clone, modify, and push to feature collection
            var destinationFeature = JSON.parse(JSON.stringify(feature));

            destinationFeature.properties.isOrigin = false;
            destinationFeature.properties._isSelectedForPathDisplay = false;
            destinationFeature.properties._uniqueId = index + '_destination';

            destinationFeature.geometry.coordinates = [
              destinationFeature.properties[configDestinationGeometryObject.x],
              destinationFeature.properties[configDestinationGeometryObject.y]
            ];

            geoJsonFeatureCollection.features.push(destinationFeature);
          }
        }, this);

        // all origin/destination features are available for future internal used
        // but only a filtered subset of these are drawn on the map
        this.originAndDestinationGeoJsonPoints = geoJsonFeatureCollection;
        var geoJsonPointsToDraw = this._filterGeoJsonPointsToDraw(geoJsonFeatureCollection);
        this.addData(geoJsonPointsToDraw);
      } else {
        // TODO: improved handling of invalid incoming GeoJson FeatureCollection?
        this.originAndDestinationGeoJsonPoints = null;
      }

      return this;
    },

    onAdd: function(map) {
      // call the L.GeoJSON onAdd method,
      // then continue with custom code
      L.GeoJSON.prototype.onAdd.call(this, map);

      // create new canvas element for optional, animated bezier curves
      this._animationCanvasElement = this._insertCustomCanvasElement(map, this.options);

      // create new canvas element for manually drawing bezier curves
      //  - most of the magic happens in this canvas element
      //  - this canvas element is established last because it will be
      //    inserted before (underneath) the animation canvas element
      this._canvasElement = this._insertCustomCanvasElement(map, this.options);

      // create a reference to both canvas elements in an array for convenience
      this._customCanvases = [this._canvasElement, this._animationCanvasElement]

      // establish custom event listeners
      this.on('click mouseover', this._modifyInteractionEvent, this);
      map.on('move', this._resetCanvas, this);
      map.on('moveend', this._resetCanvasAndWrapGeoJsonCircleMarkers, this);
      map.on('resize', this._resizeCanvas, this);
      if (map.options.zoomAnimation && L.Browser.any3d) {
        map.on('zoomanim', this._animateZoom, this);
      }

      // calculate initial size and position of canvas
      // and draw its content for the first time
      this._resizeCanvas();
      this._resetCanvasAndWrapGeoJsonCircleMarkers();

      return this;
    },

    onRemove: function(map) {
      // call the L.GeoJSON onRemove method,
      // then continue with custom code
      L.GeoJSON.prototype.onRemove.call(this, map);

      this._clearCanvas();

      this._customCanvases.forEach(function(canvas) {
        L.DomUtil.remove(canvas);
      });

      // remove custom event listeners
      this.off('click mouseover', this._modifyInteractionEvent, this);
      map.off('move', this._resetCanvas, this);
      map.off('moveend', this._resetCanvasAndWrapGeoJsonCircleMarkers, this);
      map.off('resize', this._resizeCanvas, this);
      if (map.options.zoomAnimation) {
        map.off('zoomanim', this._animateZoom, this);
      }

      return this;
    },

    bringToBack: function() {
      // call the L.GeoJSON bringToBack method to manage the point graphics    
      L.GeoJSON.prototype.bringToBack.call(this);

      // keep the animation canvas element on top of the main canvas element
      L.DomUtil.toBack(this._animationCanvasElement);

      // keep the main canvas element underneath the animation canvas element
      L.DomUtil.toBack(this._canvasElement);

      return this;
    },

    bringToFront: function() {
      // keep the main canvas element underneath the animation canvas element
      L.DomUtil.toFront(this._canvasElement);

      // keep the animation canvas element on top of the main canvas element
      L.DomUtil.toFront(this._animationCanvasElement);

      // call the L.GeoJSON bringToFront method to manage the point graphics
      L.GeoJSON.prototype.bringToFront.call(this);

      return this;
    },

    setAnimationDuration: function(milliseconds) {
      milliseconds = Number(milliseconds) || this.options.animationDuration;

      // change the tween duration on the active animation tween
      if (this._animationTween) {
        this._animationTween.to({
          offset: this._animationPropertiesStatic.resetOffset
        }, milliseconds);
      }

      this._animationPropertiesDynamic.duration = milliseconds;
    },

    setAnimationEasing: function(easingFamily, easingType) {
      var tweenEasingFunction;
      if (
        TWEEN.Easing.hasOwnProperty(easingFamily) &&
        TWEEN.Easing[easingFamily].hasOwnProperty(easingType)
      ) {
        tweenEasingFunction = TWEEN.Easing[easingFamily][easingType];
      } else {
        easingFamily = this.options.animationEasingFamily;
        easingType = this.options.animationEasingType;
        tweenEasingFunction = TWEEN.Easing[easingFamily][easingType];
      }

      // change the tween easing function on the active animation tween
      if (this._animationTween) {
        this._animationTween.easing(tweenEasingFunction);
      }

      this._animationPropertiesDynamic.easingInfo = {
        easingFamily: easingFamily,
        easingType: easingType,
        tweenEasingFunction: tweenEasingFunction
      };
    },

    getAnimationEasingOptions: function(prettyPrint) {
      var tweenEasingConsoleOptions = {};
      var tweenEasingOptions = {};

      Object.keys(TWEEN.Easing).forEach(function(family) {
        tweenEasingConsoleOptions[family] = {
          types: Object.keys(TWEEN.Easing[family]).join('", "')
        };

        tweenEasingOptions[family] = {
          types: Object.keys(TWEEN.Easing[family])
        };
      });

      if (!!prettyPrint) {
        console.table(tweenEasingConsoleOptions);
      }

      return tweenEasingOptions;
    },

    playAnimation: function() {
      this.options.animationStarted = true;
      this._redrawCanvas();
    },

    stopAnimation: function() {
      this.options.animationStarted = false;
      this._redrawCanvas();
    },

    selectFeaturesForPathDisplay: function(selectionFeatures, selectionMode) {
      this._applyFeaturesSelection(selectionFeatures, selectionMode, '_isSelectedForPathDisplay');
    },

    selectFeaturesForPathDisplayById: function(uniqueOriginOrDestinationIdField, idValue, originBoolean, selectionMode) {
      if (
        uniqueOriginOrDestinationIdField !== this.options.originAndDestinationFieldIds.originUniqueIdField &&
        uniqueOriginOrDestinationIdField !== this.options.originAndDestinationFieldIds.destinationUniqueIdField
      ) {
        console.error('Invalid unique id field supplied for origin or destination. It must be one of these: ' +
          this.options.originAndDestinationFieldIds.originUniqueIdField + ', ' + this.options.originAndDestinationFieldIds.destinationUniqueIdField);
        return;
      }

      var existingOriginOrDestinationFeature = this.originAndDestinationGeoJsonPoints.features.filter(function(feature) {
        return feature.properties.isOrigin === originBoolean &&
          feature.properties[uniqueOriginOrDestinationIdField] === idValue;
      })[0];

      var odInfo = this._getSharedOriginOrDestinationFeatures(existingOriginOrDestinationFeature);

      if (odInfo.isOriginFeature) {
        this.selectFeaturesForPathDisplay(odInfo.sharedOriginFeatures, selectionMode);
      } else {
        this.selectFeaturesForPathDisplay(odInfo.sharedDestinationFeatures, selectionMode);
      }
    },

    clearAllPathSelections: function() {
      this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {
        feature.properties._isSelectedForPathDisplay = false;
      });

      this._resetCanvas();
    },

    selectAllFeaturesForPathDisplay: function() {
      this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {
        if (feature.properties.isOrigin) {
          feature.properties._isSelectedForPathDisplay = true;
        } else {
          feature.properties._isSelectedForPathDisplay = false;
        }
      });

      this._resetCanvas();
    },

    _filterGeoJsonPointsToDraw: function(geoJsonFeatureCollection) {
      var newGeoJson = {
        type: 'FeatureCollection',
        features: []
      };

      var originUniqueIdValues = [];
      var destinationUniqueIdValues = [];

      var originUniqueIdField = this.options.originAndDestinationFieldIds.originUniqueIdField;
      var destinationUniqueIdField = this.options.originAndDestinationFieldIds.destinationUniqueIdField;

      geoJsonFeatureCollection.features.forEach(function(feature) {
        var isOrigin = feature.properties.isOrigin;

        if (isOrigin && originUniqueIdValues.indexOf(feature.properties[originUniqueIdField]) === -1) {
          originUniqueIdValues.push(feature.properties[originUniqueIdField]);
          newGeoJson.features.push(feature);
        } else if (!isOrigin && destinationUniqueIdValues.indexOf(feature.properties[destinationUniqueIdField]) === -1) {
          destinationUniqueIdValues.push(feature.properties[destinationUniqueIdField]);
          newGeoJson.features.push(feature);
        } else {
          // do not attempt to draw an origin or destination circle on the canvas if it is already in one of the tracking arrays
          return;
        }
      });

      return newGeoJson;
    },

    _insertCustomCanvasElement: function(map, options) {
      var canvas = L.DomUtil.create('canvas', 'leaflet-zoom-animated');

      var originProp = L.DomUtil.testProp(['transformOrigin', 'WebkitTransformOrigin', 'msTransformOrigin']);
      canvas.style[originProp] = '50% 50%';

      var pane = map.getPane(options.pane);
      pane.insertBefore(canvas, pane.firstChild);

      return canvas;
    },

    _modifyInteractionEvent: function(e) {
      var odInfo = this._getSharedOriginOrDestinationFeatures(e.layer.feature);
      e.isOriginFeature = odInfo.isOriginFeature;
      e.sharedOriginFeatures = odInfo.sharedOriginFeatures;
      e.sharedDestinationFeatures = odInfo.sharedDestinationFeatures;
    },

    _getSharedOriginOrDestinationFeatures: function(testFeature) {
      var isOriginFeature = testFeature.properties.isOrigin;
      var sharedOriginFeatures = [];
      var sharedDestinationFeatures = [];

      if (isOriginFeature) {
        // for an ORIGIN point that was interacted with,
        // make an array of all other ORIGIN features with the same ORIGIN ID field
        var originUniqueIdField = this.options.originAndDestinationFieldIds.originUniqueIdField;
        var testFeatureOriginId = testFeature.properties[originUniqueIdField];
        sharedOriginFeatures = this.originAndDestinationGeoJsonPoints.features.filter(function(feature) {
          return feature.properties.isOrigin &&
            feature.properties[originUniqueIdField] === testFeatureOriginId;
        });
      } else {
        // for a DESTINATION point that was interacted with,
        // make an array of all other ORIGIN features with the same DESTINATION ID field
        var destinationUniqueIdField = this.options.originAndDestinationFieldIds.destinationUniqueIdField;
        var testFeatureDestinationId = testFeature.properties[destinationUniqueIdField];
        sharedDestinationFeatures = this.originAndDestinationGeoJsonPoints.features.filter(function(feature) {
          return feature.properties.isOrigin &&
            feature.properties[destinationUniqueIdField] === testFeatureDestinationId;
        });
      }

      return {
        isOriginFeature: isOriginFeature, // Boolean
        sharedOriginFeatures: sharedOriginFeatures, // Array of features
        sharedDestinationFeatures: sharedDestinationFeatures // Array of features
      };
    },

    _applyFeaturesSelection: function(selectionFeatures, selectionMode, selectionAttributeName) {
      var selectionIds = selectionFeatures.map(function(feature) {
        return feature.properties._uniqueId;
      });

      if (selectionMode === 'SELECTION_NEW') {
        this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {
          if (selectionIds.indexOf(feature.properties._uniqueId) > -1) {
            feature.properties[selectionAttributeName] = true;
          } else {
            feature.properties[selectionAttributeName] = false;
          }
        });
      } else if (selectionMode === 'SELECTION_ADD') {
        this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {
          if (selectionIds.indexOf(feature.properties._uniqueId) > -1) {
            feature.properties[selectionAttributeName] = true;
          }
        });
      } else if (selectionMode === 'SELECTION_SUBTRACT') {
        this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {
          if (selectionIds.indexOf(feature.properties._uniqueId) > -1) {
            feature.properties[selectionAttributeName] = false;
          }
        });
      } else {
        return;
      }

      this._resetCanvas();
    },

    _animateZoom: function(e) {
      // see: https://github.com/Leaflet/Leaflet.heat
      var scale = this._map.getZoomScale(e.zoom);
      var offset = this._map._getCenterOffset(e.center)._multiplyBy(-scale).subtract(this._map._getMapPanePos());

      if (L.DomUtil.setTransform) {
        this._customCanvases.forEach(function(canvas) {
          L.DomUtil.setTransform(canvas, offset, scale);
        });
      } else {
        this._customCanvases.forEach(function(canvas) {
          canvas.style[L.DomUtil.TRANSFORM] = L.DomUtil.getTranslateString(offset) + ' scale(' + scale + ')';
        });
      }
    },

    _resizeCanvas: function() {
      // update the canvas size
      var size = this._map.getSize();
      this._customCanvases.forEach(function(canvas) {
        canvas.width = size.x;
        canvas.height = size.y;
      });

      this._resetCanvas();
    },

    _resetCanvas: function() {
      if (!this._map) {
        // stop early if the layer is not currently on the map
        return;
      }

      // update the canvas position and redraw its content
      var topLeft = this._map.containerPointToLayerPoint([0, 0]);
      this._customCanvases.forEach(function(canvas) {
        L.DomUtil.setPosition(canvas, topLeft);
      });

      this._redrawCanvas();
    },

    _resetCanvasAndWrapGeoJsonCircleMarkers: function() {
      this._resetCanvas();
      // Leaflet will redraw a CircleMarker when its latLng is changed
      // sometimes they are drawn 2+ times if this occurs during many "move" events
      // so for now, only chang CircleMarker latlng after a single "moveend" event
      this._wrapGeoJsonCircleMarkers();
    },

    _redrawCanvas: function() {
      // draw canvas content (only the Bezier curves)
      if (this._map && this.originAndDestinationGeoJsonPoints) {
        this._clearCanvas();

        // loop over each of the "selected" features and re-draw the canvas paths
        this._drawSelectedCanvasPaths(false);

        if (this._animationFrameId) {
          L.Util.cancelAnimFrame(this._animationFrameId);
        }

        if (
          this.options.animationStarted &&
          this.originAndDestinationGeoJsonPoints.features.some(function(feature) {
            return feature.properties._isSelectedForPathDisplay;
          })
        ) {
          // start animation loop if the layer is currently set for showing animations,
          // and if there is at least 1 feature selected for displaying paths
          this._animator();
        }
      }
    },

    _clearCanvas: function() {
      this._customCanvases.forEach(function(canvas) {
        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
      });

      if (this._animationFrameId) {
        L.Util.cancelAnimFrame(this._animationFrameId);
      }
    },

    _drawSelectedCanvasPaths: function(animate) {
      var ctx = animate ?
        this._animationCanvasElement.getContext('2d') :
        this._canvasElement.getContext('2d');

      var originAndDestinationFieldIds = this.options.originAndDestinationFieldIds;

      this.originAndDestinationGeoJsonPoints.features.forEach(function(feature) {

        if (feature.properties._isSelectedForPathDisplay) {
          var originXCoordinate = feature.properties[originAndDestinationFieldIds.originGeometry.x];
          var originYCoordinate = feature.properties[originAndDestinationFieldIds.originGeometry.y];
          var destinationXCoordinate = feature.properties[originAndDestinationFieldIds.destinationGeometry.x];
          var destinationYCoordinate = feature.properties[originAndDestinationFieldIds.destinationGeometry.y];

          // origin and destination points for drawing curved lines
          // ensure that canvas features will be drawn beyond +/-180 longitude
          var originLatLng = this._wrapAroundLatLng(L.latLng([originYCoordinate, originXCoordinate]));
          var destinationLatLng = this._wrapAroundLatLng(L.latLng([destinationYCoordinate, destinationXCoordinate]));

          // convert geometry to screen coordinates for canvas drawing
          var screenOriginPoint = this._map.latLngToContainerPoint(originLatLng);
          var screenDestinationPoint = this._map.latLngToContainerPoint(destinationLatLng);

          // get the canvas symbol properties,
          // and draw a curved canvas line
          var symbol;
          if (animate) {
            symbol = this._getSymbolProperties(feature, this.options.animatedCanvasBezierStyle);
            ctx.beginPath();
            this._animateCanvasLineSymbol(ctx, symbol, screenOriginPoint, screenDestinationPoint);
            ctx.stroke();
            ctx.closePath();
          } else {
            symbol = this._getSymbolProperties(feature, this.options.canvasBezierStyle);
            ctx.beginPath();
            this._applyCanvasLineSymbol(ctx, symbol, screenOriginPoint, screenDestinationPoint);
            ctx.stroke();
            ctx.closePath();
          }
        }
      }, this);
    },

    _getSymbolProperties: function(feature, canvasSymbolConfig) {
      // get the canvas symbol properties
      var symbol;
      var filteredSymbols;
      if (canvasSymbolConfig.type === 'simple') {
        symbol = canvasSymbolConfig.symbol;
      } else if (canvasSymbolConfig.type === 'uniqueValue') {
        filteredSymbols = canvasSymbolConfig.uniqueValueInfos.filter(function(info) {
          return info.value === feature.properties[canvasSymbolConfig.field];
        });
        symbol = filteredSymbols[0].symbol;
      } else if (canvasSymbolConfig.type === 'classBreaks') {
        filteredSymbols = canvasSymbolConfig.classBreakInfos.filter(function(info) {
          return (
            info.classMinValue <= feature.properties[canvasSymbolConfig.field] &&
            info.classMaxValue >= feature.properties[canvasSymbolConfig.field]
          );
        });
        if (filteredSymbols.length) {
          symbol = filteredSymbols[0].symbol;
        } else {
          symbol = canvasSymbolConfig.defaultSymbol;
        }
      }
      return symbol;
    },

    _applyCanvasLineSymbol: function(ctx, symbolObject, screenOriginPoint, screenDestinationPoint) {
      ctx.lineCap = symbolObject.lineCap;
      ctx.lineWidth = symbolObject.lineWidth;
      ctx.strokeStyle = symbolObject.strokeStyle;
      ctx.shadowBlur = symbolObject.shadowBlur;
      ctx.shadowColor = symbolObject.shadowColor;
      ctx.moveTo(screenOriginPoint.x, screenOriginPoint.y);
      ctx.bezierCurveTo(screenOriginPoint.x, screenDestinationPoint.y, screenDestinationPoint.x, screenDestinationPoint.y, screenDestinationPoint.x, screenDestinationPoint.y);
    },

    _animateCanvasLineSymbol: function(ctx, symbolObject, screenOriginPoint, screenDestinationPoint) {
      ctx.lineCap = symbolObject.lineCap;
      ctx.lineWidth = symbolObject.lineWidth;
      ctx.strokeStyle = symbolObject.strokeStyle;
      ctx.shadowBlur = symbolObject.shadowBlur;
      ctx.shadowColor = symbolObject.shadowColor;
      ctx.setLineDash([symbolObject.lineDashOffsetSize, (this._animationPropertiesStatic.resetOffset - symbolObject.lineDashOffsetSize)]);
      ctx.lineDashOffset = -this._animationPropertiesStatic.offset; // this makes the dot appear to move when the entire top canvas is redrawn
      ctx.moveTo(screenOriginPoint.x, screenOriginPoint.y);
      ctx.bezierCurveTo(screenOriginPoint.x, screenDestinationPoint.y, screenDestinationPoint.x, screenDestinationPoint.y, screenDestinationPoint.x, screenDestinationPoint.y);
    },

    _animator: function(time) {
      this._animationCanvasElement.getContext('2d')
        .clearRect(0, 0, this._animationCanvasElement.width, this._animationCanvasElement.height);

      this._drawSelectedCanvasPaths(true); // draw it again to give the appearance of a moving dot with a new lineDashOffset

      TWEEN.update(time);

      this._animationFrameId = L.Util.requestAnimFrame(this._animator, this);
    },

    _wrapGeoJsonCircleMarkers: function() {
      // ensure that the GeoJson point features,
      // which are drawn on the map as individual CircleMarker layers,
      // will be drawn beyond +/-180 longitude
      this.eachLayer(function(layer) {
        var wrappedLatLng = this._wrapAroundLatLng(layer.getLatLng());
        layer.setLatLng(wrappedLatLng);
      }, this);
    },

    _wrapAroundLatLng: function(latLng) {
      if (this._map && this.options.wrapAroundCanvas) {
        var wrappedLatLng = latLng.clone();
        var mapCenterLng = this._map.getCenter().lng;
        var wrapAroundDiff = mapCenterLng - wrappedLatLng.lng;
        if (wrapAroundDiff < -180 || wrapAroundDiff > 180) {
          wrappedLatLng.lng += (Math.round(wrapAroundDiff / 360) * 360);
        }
        return wrappedLatLng;
      } else {
        return latLng;
      }
    }
  });

  return CanvasFlowmapLayer;

}, window));