js/motion-path.coffee

class MotionPath

MotionPath

Class for moving object along path or curve

h = require './h' easing = require './easing' resize = require './vendor/resize' Timeline = require './tween/timeline' Tween = require './tween/tween' class MotionPath

Defaults/APIs


defaults:

Propery path of type String, SVGPathElement, Object

Defines motion path or arc to animate el's position.
Can be defined

  • by String:
    • CSS selector e.g. '#js-path', '.path' etc
    • SVG path line commands e.g 'M0,0 L100, 300'
  • by SVGPathElement e.g document.getElementById('#js-path')
  • by Arc shift e.g { x: 200, y: 100 }. If motion path was defined by arc shift, curvature option defines arc curvature.

CSS selector:

SVG line commands:

SVGPathElement:

Arc shift:

path: null

Propery curvature of type Object

Defines curve size for path defined by arc shift.
Curvature amount can be defined by number representing px or percents(string) representing amount relative to shift length.

Example:

{ x: 200, y: 100 } or { x: '50%', y: '20%' } or mix

Example:

// will fallback to defaults for omitted axes
{ x: 200 }   // fallbacks to { x: 200, y: '50%' }
{ y: '25%' } // fallbacks to { x: '75%', y: '25%' }

curvature: x: '75%', y: '50%'

Propery delay of type Number

Delay before animation starts, ms

delay: 0

Propery duration of type Number

Duration of animation, ms

duration: 1000

Propery easing of type String, Function, Array

Easing. The option will be passed to timeline.parseEasing method. Please see the timeline module for all avaliable options.

String:

Bezier cubic curve:

Custom function:

easing: null

Propery repeat of type Integer

Animation repeat count

repeat: 0

Propery yoyo of type Boolean

Defines if animation should be alternated on repeat.

yoyo: false

Propery offsetX of type Number

Defines additional horizontal offset from center of path, px

offsetX: 0

Propery offsetY of type Number

Defines additional vertical offset from center of path, px

offsetY: 0

Propery angleOffset of type Number, Function

Defines angle offset for path curves

Example:

// function
new MotionPath({
  //...
  angleOffset: function(currentAngle) {
    return if (currentAngle < 0) { 90 } else {-90}
  }
});

Number:

Function:

angleOffset: null

Propery pathStart of type Number

Defines lower bound for path coordinates in rangle [0,1] So specifying pathStart of .5 will start animation form the 50% progress of your path.

Example:

// function
new MotionPath({
  //...
  pathStart: .5
});

pathStart: 0

Propery pathEnd of type Number

Defines upper bound for path coordinates in rangle [0,1] So specifying pathEnd of .5 will end animation at the 50% progress of your path.

Example:

// function
new MotionPath({
  //...
  pathEnd: .5
});

pathEnd: 1

Propery motionBlur of type Number

Defines motion blur on element in range of [0,1]

motionBlur: 0

Propery transformOrigin of type String, Function

Defines transform-origin CSS property for el. Can be defined by string or function. Function recieves current angle as agrumnet and should return transform-origin value as a strin.

Example:

// function
new MotionPath({
  //...
  isAngle: true,
  transformOrigin: function (currentAngle) {
    return  6*currentAngle + '% 0';
  }
});

Function:

transformOrigin: null

Propery isAngle of type Boolean

Defines if path curves angle should be set to el.

isAngle: false

Propery isReverse of type Boolean

Defines motion path direction.

isReverse: false

Propery isRunLess of type Boolean

Defines if animation should not start after init. Animation can be then started with calling run method.

Please see at codepen for proper results:

isRunLess: false

Propery isPresetPosition of type Boolean

Defines if el's position should be preset immediately after init. If set to false el will remain at it's position until actual animation started on delay end or run method call.

isPresetPosition: true

Propery onStart of type Function

Callback onStart fires once if animation was started.

onStart: null

Propery onComplete of type Function

Callback onComplete fires once if animation was completed.

onComplete: null

Propery onUpdate of type Function

Callback onUpdate fires every raf frame on motion path update. Recieves progress of type Number in range [0,1] as argument.

onUpdate: null

Propery onPosit of type Function

Callback onPosit fires every raf frame on motion path update. Recieves current progress, x, y and angle of type Number. Returned value will be set as el's transform

onPosit: null

Class body docs


constructor:(@o={})-> return if @vars(); @createTween(); @ vars:-> @getScaler = h.bind(@getScaler, @); @resize = resize @props = h.cloneObj(@defaults) @extendOptions @o

reset motionBlur for safari and IE

@isMotionBlurReset = h.isSafari or h.isIE @isMotionBlurReset and (@props.motionBlur = 0) @history = [h.cloneObj(@props)] @postVars()

Method curveToPath

Method to transform coordinates and curvature to svg path

Parameters:

  • coordinates must be an Object.
    (of end point x and y)
  • coordinates must be an Object.
    (of the control point of the quadratic bezier curve, relative to start and end coordinates x and y)

Returns a SVGElement
(svg path)

curveToPath:(o)-> path = document.createElementNS h.NS , 'path' start = o.start endPoint = x: start.x + o.shift.x, y: start.x + o.shift.y curvature = o.curvature dX = o.shift.x; dY = o.shift.y radius = Math.sqrt(dX*dX + dY*dY); percent = radius/100 angle = Math.atan(dY/dX)*(180/Math.PI) + 90 if o.shift.x < 0 then angle = angle + 180

get point on line between start end end

curvatureX = h.parseUnit curvature.x curvatureX = if curvatureX.unit is '%' then curvatureX.value*percent else curvatureX.value curveXPoint = h.getRadialPoint center: x: start.x, y: start.y radius: curvatureX angle: angle

get control point with center in curveXPoint

curvatureY = h.parseUnit curvature.y curvatureY = if curvatureY.unit is '%' then curvatureY.value*percent else curvatureY.value curvePoint = h.getRadialPoint center: x: curveXPoint.x, y: curveXPoint.y radius: curvatureY angle: angle+90 path.setAttribute 'd', "M#{start.x},#{start.y} Q#{curvePoint.x},#{curvePoint.y} #{endPoint.x},#{endPoint.y}" path postVars:-> @props.pathStart = h.clamp @props.pathStart, 0, 1 @props.pathEnd = h.clamp @props.pathEnd, @props.pathStart, 1 @angle = 0; @speedX = 0; @speedY = 0; @blurX = 0; @blurY = 0 @prevCoords = {}; @blurAmount = 20

clamp motionBlur in range of [0,1]

@props.motionBlur = h.clamp @props.motionBlur, 0, 1 @onUpdate = @props.onUpdate @el = @parseEl @props.el @props.motionBlur > 0 and @createFilter() @path = @getPath() if !@path.getAttribute('d') h.error('Path has no coordinates to work with, aborting'); return true @len = @path.getTotalLength() @slicedLen = @len*(@props.pathEnd - @props.pathStart) @startLen = @props.pathStart*@len @fill = @props.fill if @fill? @container = @parseEl @props.fill.container @fillRule = @props.fill.fillRule or 'all' @getScaler() if @container? @removeEvent @container, 'onresize', @getScaler @addEvent @container, 'onresize', @getScaler addEvent: (el, type, handler)-> el.addEventListener type, handler, false removeEvent:(el, type, handler)-> el.removeEventListener type, handler, false createFilter:-> div = document.createElement 'div' @filterID = "filter-#{h.getUniqID()}" div.innerHTML = """<svg id="svg-#{@filterID}" style="visibility:hidden; width:0px; height:0px"> <filter id="#{@filterID}" y="-20" x="-20" width="40" height="40"> <feOffset id="blur-offset" in="SourceGraphic" dx="0" dy="0" result="offset2"></feOffset> <feGaussianblur id="blur" in="offset2" stdDeviation="0,0" result="blur2"></feGaussianblur> <feMerge> <feMergeNode in="SourceGraphic"></feMergeNode> <feMergeNode in="blur2"></feMergeNode> </feMerge> </filter> </svg>""" svg = div.querySelector "#svg-#{@filterID}" @filter = svg.querySelector '#blur' @filterOffset = svg.querySelector '#blur-offset' document.body.insertBefore svg, document.body.firstChild @el.style['filter'] = "url(##{@filterID})" @el.style["#{h.prefix.css}filter"] = "url(##{@filterID})" parseEl:(el)-> return document.querySelector el if typeof el is 'string' return el if el instanceof HTMLElement if el.setProp? then @isModule = true; return el getPath:-> path = h.parsePath(@props.path); return path if path if @props.path.x or @props.path.y @curveToPath start: x: 0, y: 0 shift: {x: (@props.path.x or 0), y: (@props.path.y or 0)} curvature: x: @props.curvature.x or @defaults.curvature.x y: @props.curvature.y or @defaults.curvature.y getScaler:()-> @cSize = width: @container.offsetWidth or 0 height: @container.offsetHeight or 0 start = @path.getPointAtLength 0 end = @path.getPointAtLength @len size = {}; @scaler = {} size.width = if end.x >= start.x then end.x-start.x else start.x-end.x size.height = if end.y >= start.y then end.y-start.y else start.y-end.y switch @fillRule when 'all' @calcWidth(size); @calcHeight(size) when 'width' @calcWidth(size); @scaler.y = @scaler.x when 'height' @calcHeight(size); @scaler.x = @scaler.y
calcWidth:(size)-> @scaler.x = @cSize.width/size.width !isFinite(@scaler.x) and (@scaler.x = 1) calcHeight:(size)=> @scaler.y = @cSize.height/size.height !isFinite(@scaler.y) and (@scaler.y = 1) run:(o)-> if o fistItem = @history[0] for key, value of o if h.callbacksMap[key] or h.tweenOptionMap[key] h.warn "the property \"#{key}\" property can not be overridden on run yet" delete o[key] else @history[0][key] = value @tuneOptions o @startTween() createTween:-> @timeline = new Timeline duration: @props.duration delay: @props.delay yoyo: @props.yoyo repeat: @props.repeat easing: @props.easing onStart: => @props.onStart?.apply @ onComplete: => @props.motionBlur and @setBlur blur: {x: 0, y: 0}, offset: {x: 0, y: 0} @props.onComplete?.apply @ onUpdate: (p)=> @setProgress(p) onFirstUpdateBackward:=> @history.length > 1 and @tuneOptions @history[0] @tween = new Tween# onUpdate:(p)=> @o.onChainUpdate?(p) @tween.add(@timeline) !@props.isRunLess and @startTween() @props.isPresetPosition and @setProgress(0, true) startTween:-> setTimeout (=> @tween?.start()), 1 setProgress:(p, isInit)-> props = @props len = @startLen+if !props.isReverse then p*@slicedLen else (1-p)*@slicedLen point = @path.getPointAtLength len isTransformFunOrigin = typeof props.transformOrigin is 'function'

get current angle

if props.isAngle or props.angleOffset? or isTransformFunOrigin prevPoint = @path.getPointAtLength len - 1 x1 = point.y - prevPoint.y; x2 = point.x - prevPoint.x atan = Math.atan(x1/x2); !isFinite(atan) and (atan = 0) @angle = atan*h.RAD_TO_DEG if (typeof props.angleOffset) isnt 'function' @angle += props.angleOffset or 0 else @angle = props.angleOffset.call @, @angle, p else @angle = 0

get x and y coordinates

x = point.x + @props.offsetX; y = point.y + @props.offsetY @props.motionBlur and @makeMotionBlur(x, y)

get real coordinates relative to container size

if @scaler then x *= @scaler.x; y *= @scaler.y

set position and angle

if @isModule then @setModulePosition(x,y) else @setElPosition(x,y,p)

set transform origin

if @props.transformOrigin

transform origin could be a function

tOrigin = if !isTransformFunOrigin then @props.transformOrigin else @props.transformOrigin(@angle, p) @el.style["#{h.prefix.css}transform-origin"] = tOrigin @el.style['transform-origin'] = tOrigin

call onUpdate but not on the very first(0 progress) call

!isInit and @onUpdate?(p) setElPosition:(x,y,p)-> transform = if !@props.onPosit? rotate = if @angle isnt 0 then "rotate(#{@angle}deg)" else '' "translate(#{x}px,#{y}px) #{rotate}" else @props.onPosit p, x, y, @angle @el.style["#{h.prefix.css}transform"] = transform @el.style['transform'] = transform setModulePosition:(x, y)-> @el.setProp shiftX: "#{x}px", shiftY: "#{y}px", angle: @angle @el.draw() makeMotionBlur:(x, y)->

if previous coords are not defined yet -- set speed to 0

tailAngle = 0; signX = 1; signY = 1 if !@prevCoords.x? or !@prevCoords.y? then @speedX = 0; @speedY = 0

else calculate speed based on the largest axes delta

else dX = x-@prevCoords.x; dY = y-@prevCoords.y if dX > 0 then signX = -1 if signX < 0 then signY = -1 @speedX = Math.abs(dX); @speedY = Math.abs(dY) tailAngle = Math.atan(dY/dX)*(180/Math.PI) + 90 absoluteAngle = tailAngle - @angle coords = @angToCoords absoluteAngle

get blur based on speed where 1px per 1ms is very fast and motionBlur coefficient

@blurX = h.clamp (@speedX/16)*@props.motionBlur, 0, 1 @blurY = h.clamp (@speedY/16)*@props.motionBlur, 0, 1 @setBlur blur: x: 3*@blurX*@blurAmount*Math.abs(coords.x) y: 3*@blurY*@blurAmount*Math.abs(coords.y) offset: x: 3*signX*@blurX*coords.x*@blurAmount y: 3*signY*@blurY*coords.y*@blurAmount

save previous coords

@prevCoords.x = x; @prevCoords.y = y setBlur:(o)-> if !@isMotionBlurReset @filter.setAttribute 'stdDeviation', "#{o.blur.x},#{o.blur.y}" @filterOffset.setAttribute 'dx', o.offset.x @filterOffset.setAttribute 'dy', o.offset.y extendDefaults:(o)-> for key, value of o @[key] = value extendOptions:(o)-> for key, value of o @props[key] = value then:(o)-> prevOptions = @history[@history.length-1]; opts = {} for key, value of prevOptions

don't copy callbacks and tween options(only duration) get prev options if not defined

if !h.callbacksMap[key] and !h.tweenOptionMap[key] or key is 'duration' o[key] ?= value

if property is callback and not defined in then options - define it as undefined :) to override old callback, because we are inside the prevOptions hash and it means the callback was previously defined

else o[key] ?= undefined

get tween timing values to feed the timeline

if h.tweenOptionMap[key]

copy all props, if prop is duration - fallback to previous value

opts[key] = if key isnt 'duration' then o[key] else if o[key]? then o[key] else prevOptions[key] @history.push(o); it = @ opts.onUpdate = (p)=> @setProgress p opts.onStart = => @props.onStart?.apply @ opts.onComplete = => @props.onComplete?.apply @ opts.onFirstUpdate = -> it.tuneOptions it.history[@index] opts.isChained = !o.delay @tween.append new Timeline(opts) @ tuneOptions:(o)-> @extendOptions(o); @postVars() angToCoords:(angle)-> angle = angle % 360 radAngle = ((angle-90)*Math.PI)/180 x = Math.cos(radAngle); y = Math.sin(radAngle) x = if x < 0 then Math.max(x, -0.7) else Math.min(x, .7) y = if y < 0 then Math.max(y, -0.7) else Math.min(y, .7) x: x*1.428571429 y: y*1.428571429

x: Math.cos(radAngle), y: Math.sin(radAngle)

module.exports = MotionPath