packages feed

reanimate-1.1.5.0: src/Reanimate/Animation.hs

{-|
Module      : Reanimate.Animation
Copyright   : Written by David Himmelstrup
License     : Unlicense
Maintainer  : lemmih@gmail.com
Stability   : experimental
Portability : POSIX

Declarative animation API based on combinators. For a higher-level interface,
see 'Reanimate.Scene'.

-}
module Reanimate.Animation
  ( Duration
  , Time
  , SVG
  , Animation
  -- * Creating animations
  , mkAnimation
  , unsafeMkAnimation
  , animate
  , staticFrame
  , pause
  -- * Querying animations
  , duration
  , frameAt
  -- * Composing animations
  , seqA
  , andThen
  , parA
  , parLoopA
  , parDropA
  -- * Modifying animations
  , setDuration
  , adjustDuration
  , mapA
  , takeA
  , dropA
  , lastA
  , pauseAtEnd
  , pauseAtBeginning
  , pauseAround
  , repeatA
  , reverseA
  , playThenReverseA
  , signalA
  , freezeAtPercentage
  , addStatic
  -- * Misc
  , getAnimationFrame
  , Sync(..)
  -- * Rendering
  , renderTree
  , renderSvg
  ) where

import           Data.Fixed                 (mod')
import           Graphics.SvgTree
import           Graphics.SvgTree.Printer   (ppDocument)
import           Reanimate.Constants        (defaultStrokeWidth)
import           Reanimate.Ease             (Signal, reverseS)
import           Reanimate.Svg.Constructors (mkGroup, scaleXY, withStrokeWidth)
import           Text.XML.Light.Output      (ppElement)

-- | Duration of an animation or effect. Usually measured in seconds.
type Duration = Double
-- | Time signal. Goes from 0 to 1, inclusive.
type Time = Double

-- | SVG node.
type SVG = Tree

-- | Animations are SVGs over a finite time.
data Animation = Animation Duration (Time -> SVG)

-- | Construct an animation with a given duration. If the duration is not
--   positive throws 'Prelude.error'.
mkAnimation :: Duration -> (Time -> SVG) -> Animation
mkAnimation d f
  | d > 0     = unsafeMkAnimation d f
  | otherwise = error $ "Animation duration (" ++ show d ++ ") is not positive."

-- | Construct an animation with a given duration, without checking that the
--   duration is positive.
unsafeMkAnimation :: Duration -> (Time -> SVG) -> Animation
unsafeMkAnimation = Animation

-- | Construct an animation with a duration of @1@.
animate :: (Time -> SVG) -> Animation
animate = Animation 1

-- | Create an animation with provided @duration@, which consists of stationary
--   frame displayed for its entire duration.
staticFrame :: Duration -> SVG -> Animation
staticFrame d svg = mkAnimation d (const svg)

-- | Query the duration of an animation.
duration :: Animation -> Duration
duration (Animation d _) = d

-- | Play animations in sequence. The @lhs@ animation is removed after it has
--   completed. New animation duration is '@duration lhs + duration rhs@'.
--
--   Example:
--
--   @'Reanimate.Builtin.Documentation.drawBox' `'seqA'` 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_seqA.gif>>
seqA :: Animation -> Animation -> Animation
seqA (Animation d1 f1) (Animation d2 f2) =
  Animation totalD $ \t ->
    if t < d1/totalD
      then f1 (t * totalD/d1)
      else f2 ((t-d1/totalD) * totalD/d2)
  where
    totalD = d1+d2

-- | Play two animation concurrently. Shortest animation freezes on last frame.
--   New animation duration is '@max (duration lhs) (duration rhs)@'.
--
--   Example:
--
--   @'Reanimate.Builtin.Documentation.drawBox' `'parA'` 'adjustDuration' (*2) 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_parA.gif>>
parA :: Animation -> Animation -> Animation
parA (Animation d1 f1) (Animation d2 f2) =
  Animation (max d1 d2) $ \t ->
    let t1 = t * totalD/d1
        t2 = t * totalD/d2 in
    mkGroup
    [ f1 (min 1 t1)
    , f2 (min 1 t2) ]
  where
    totalD = max d1 d2

-- | Play two animation concurrently. Shortest animation loops.
--   New animation duration is '@max (duration lhs) (duration rhs)@'.
--
--   Example:
--
--   @'Reanimate.Builtin.Documentation.drawBox' `'parLoopA'` 'adjustDuration' (*2) 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_parLoopA.gif>>
parLoopA :: Animation -> Animation -> Animation
parLoopA (Animation d1 f1) (Animation d2 f2) =
  Animation totalD $ \t ->
    let t1 = t * totalD/d1
        t2 = t * totalD/d2 in
    mkGroup
    [ f1 (t1 `mod'` 1)
    , f2 (t2 `mod'` 1) ]
  where
    totalD = max d1 d2

-- | Play two animation concurrently. Animations disappear after playing once.
--   New animation duration is '@max (duration lhs) (duration rhs)@'.
--
--   Example:
--
--   @'Reanimate.Builtin.Documentation.drawBox' `'parDropA'` 'adjustDuration' (*2) 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_parDropA.gif>>
parDropA :: Animation -> Animation -> Animation
parDropA (Animation d1 f1) (Animation d2 f2) =
  Animation totalD $ \t ->
    let t1 = t * totalD/d1
        t2 = t * totalD/d2 in
    mkGroup
    [ if t1>1 then None else f1 t1
    , if t2>1 then None else f2 t2 ]
  where
    totalD = max d1 d2

-- | Empty animation (no SVG output) with a fixed duration. If the duration is
--   not positive throws 'Prelude.error'.
--
--   Example:
--
--   @'pause' 1 `'seqA'` 'Reanimate.Builtin.Documentation.drawProgress'@
--
--   <<docs/gifs/doc_pause.gif>>
pause :: Duration -> Animation
pause d = mkAnimation d (const None)

-- | Play left animation and freeze on the last frame, then play the right
--   animation. New duration is '@duration lhs + duration rhs@'.
--
--   Example:
--
--   @'Reanimate.Builtin.Documentation.drawBox' `'andThen'` 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_andThen.gif>>
andThen :: Animation -> Animation -> Animation
andThen a b = a `parA` (pause (duration a) `seqA` b)

-- | Calculate the frame that would be displayed at given point in @time@ of
--   running @animation@.
--
--   The provided time parameter is clamped between 0 and animation duration.
frameAt :: Time -> Animation -> SVG
frameAt t (Animation d f) = f t'
  where
    t' = clamp 0 1 (t/d)

-- | Helper function for pretty-printing SVG nodes.
renderTree :: SVG -> String
renderTree t = maybe "" ppElement $ xmlOfTree t

-- | Helper function for pretty-printing SVG nodes as SVG documents.
renderSvg :: Maybe Number -- ^ The number to use as value of the @width@
                          --   attribute of the resulting top-level svg element.
                          --   If @Nothing@, the width attribute won't be
                          --   rendered.
          -> Maybe Number -- ^ Similar to previous argument, but for @height@
                          --   attribute.
          -> SVG          -- ^ SVG to render
          -> String       -- ^ String representation of SVG XML markup
renderSvg w h t = ppDocument doc
-- renderSvg w h t = ppFastElement (xmlOfDocument doc)
  where
    width = 16
    height = 9
    doc = Document
      { _documentViewBox = Just (-width/2, -height/2, width, height)
      , _documentWidth = w
      , _documentHeight = h
      , _documentElements = [withStrokeWidth defaultStrokeWidth $ scaleXY 1 (-1) t]
      , _documentDescription = ""
      , _documentLocation = ""
      , _documentAspectRatio = PreserveAspectRatio False AlignNone Nothing
      }

-- | Map over the SVG produced by an animation at every frame.
--
--   Example:
--
--   @'mapA' ('scale' 0.5) 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_mapA.gif>>

mapA :: (SVG -> SVG) -> Animation -> Animation
mapA fn (Animation d f) = Animation d (fn . f)

-- | Freeze the last frame for @t@ seconds at the end of the animation. If the
--   duration is negative throws 'Prelude.error'.
--
--   Example:
--
--   @'pauseAtEnd' 1 'Reanimate.Builtin.Documentation.drawProgress'@
--
--   <<docs/gifs/doc_pauseAtEnd.gif>>
pauseAtEnd :: Duration -> Animation -> Animation
pauseAtEnd t a
  | t == 0    = a
  | otherwise = a `andThen` pause t

-- | Freeze the first frame for @t@ seconds at the beginning of the animation.
--   If the duration is negative throws 'Prelude.error'.
--
--   Example:
--
--   @'pauseAtBeginning' 1 'Reanimate.Builtin.Documentation.drawProgress'@
--
--   <<docs/gifs/doc_pauseAtBeginning.gif>>
pauseAtBeginning :: Duration -> Animation -> Animation
pauseAtBeginning t a
  | t == 0    = a
  | otherwise = mkAnimation t (freezeFrame 0 a) `seqA` a

-- | Freeze the first and the last frame of the animation for a specified
--   duration. If a duration is negative throws 'Prelude.error'.
--
--   Example:
--
--   @'pauseAround' 1 1 'Reanimate.Builtin.Documentation.drawProgress'@
--
--   <<docs/gifs/doc_pauseAround.gif>>
pauseAround :: Duration -> Duration -> Animation -> Animation
pauseAround start end = pauseAtEnd end . pauseAtBeginning start

-- Freeze frame at time @t@.
freezeFrame :: Time -> Animation -> (Time -> SVG)
freezeFrame t (Animation d f) = const $ f (t/d)

-- | Change the duration of an animation. Animates are stretched or squished
--   (rather than truncated) to fit the new duration. If a changed duration is
--   not positive throws 'Prelude.error'.
adjustDuration :: (Duration -> Duration) -> Animation -> Animation
adjustDuration fn (Animation d gen) = mkAnimation (fn d) gen

-- | Set the duration of an animation by adjusting its playback rate. The
--   animation is still played from start to finish without being cropped. If
--   the duration is not positive throws 'Prelude.error'.
setDuration :: Duration -> Animation -> Animation
setDuration newD = adjustDuration (const newD)

-- | Play an animation in reverse. Duration remains unchanged. Shorthand for:
--   @'signalA' 'reverseS'@.
--
--   Example:
--
--   @'reverseA' 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_reverseA.gif>>
reverseA :: Animation -> Animation
reverseA = signalA reverseS

-- | Play animation before playing it again in reverse. Duration is twice
--   the duration of the input.
--
--   Example:
--
--   @'playThenReverseA' 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_playThenReverseA.gif>>
playThenReverseA :: Animation -> Animation
playThenReverseA a = a `seqA` reverseA a

-- | Loop animation @n@ number of times. This number may be fractional and it
--   may be less than 1. It must be greater than 0, though. New duration is
--   @n*duration input@. If the duration is not positive throws 'Prelude.error'.
--
--   Example:
--
--   @'repeatA' 1.5 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_repeatA.gif>>
repeatA :: Double -> Animation -> Animation
repeatA n (Animation d f) = mkAnimation (d*n) $ \t -> f ((t*n) `mod'` 1)

-- | @freezeAtPercentage time animation@ creates an animation consisting of
--   stationary frame, that would be displayed in the provided @animation@ at
--   given @time@. The duration of the new animation is the same as the duration
--   of provided @animation@.
freezeAtPercentage :: Time      -- ^ value between 0 and 1. The frame displayed
                                --   at this point in the original animation
                                --   will be displayed for the duration of the
                                --   new animation
                   -> Animation -- ^ original animation, from which the frame
                                --   will be taken
                   -> Animation -- ^ new animation consisting of static frame
                                --   displayed for the duration of the original
                                --   animation
freezeAtPercentage frac (Animation d genFrame) =
  Animation d $ const $ genFrame frac

-- | Overlay animation on top of static SVG image.
--
--  Example:
--
--  @'addStatic' ('mkBackground' "lightblue") 'Reanimate.Builtin.Documentation.drawCircle'@
--
--  <<docs/gifs/doc_addStatic.gif>>
addStatic :: SVG -> Animation -> Animation
addStatic static = mapA (\frame -> mkGroup [static, frame])

-- | Modify the time component of an animation. Animation duration is unchanged.
--
--   Example:
--
--   @'signalA' ('fromToS' 0.25 0.75) 'Reanimate.Builtin.Documentation.drawCircle'@
--
--   <<docs/gifs/doc_signalA.gif>>
signalA :: Signal -> Animation -> Animation
signalA fn (Animation d gen) = Animation d $ gen . fn

-- | @takeA duration animation@ creates a new animation consisting of initial
--   segment of @animation@ of given @duration@, played at the same rate as the
--   original animation. If the duration is not positive throws 'Prelude.error'.
--
--   The @duration@ parameter is clamped to be up to the @animation@'s duration.
--   New animation duration is equal to (eventually clamped) @duration@.
takeA :: Duration -> Animation -> Animation
takeA len a@(Animation d gen)
  | len >= d  = a
  | otherwise = mkAnimation len $ \t -> gen (t * len/d)

-- | @dropA duration animation@ creates a new animation by dropping initial
--   segment of length @duration@ from the provided @animation@, played at the
--   same rate as the original animation. If the duration greater than or equal
--   to the @animation@'s duration throws 'Prelude.error'.
--
--   The @duration@ parameter is clamped to be non-negative. The duration of the
--   resulting animation is duration of provided @animation@ minus @duration@.
dropA :: Duration -> Animation -> Animation
dropA len a@(Animation d gen)
  | len <= 0  = a
  | otherwise = mkAnimation rest $ \t -> gen (t * rest/d + len/d)
  where
    rest = d - len

-- | @lastA duration animation@ return the last @duration@ seconds of the
--   animation. If the duration is not positive throws 'Prelude.error'.
lastA :: Duration -> Animation -> Animation
lastA len a@(Animation d _) = dropA (d - len) a

clamp :: Double -> Double -> Double -> Double
clamp a b number
  | a < b     = max a (min b number)
  | otherwise = max b (min a number)

-- (#) :: a -> (a -> b) -> b
-- o # f = f o

-- | Ask for an animation frame using a given synchronization policy.
getAnimationFrame :: Sync -> Animation -> Time -> Duration -> SVG
getAnimationFrame sync (Animation aDur aGen) t d =
  case sync of
    SyncStretch -> aGen (t/d)
    SyncLoop    -> aGen (takeFrac $ t/aDur)
    SyncDrop    -> if t > aDur then None else aGen (t/aDur)
    SyncFreeze  -> aGen (min 1 $ t/aDur)
  where
    takeFrac f = snd (properFraction f :: (Int, Double))

-- | Animation synchronization policies.
data Sync
  = SyncStretch
  | SyncLoop
  | SyncDrop
  | SyncFreeze