MISCELLANEOUS TECHNICAL ARTICLES BY Dr A R COLLINS

Cango Animation User Guide

Cango animation extension

The Cango Animation extension module provides Cango canvas graphics library with additional methods to simplify animation drawing on the canvas element. These additional Cango methods are:

animation
deleteAnimation
deleteAllAnimations
playAnimation
pauseAnimation
stepAnimation
stopAnimation
redrawAnimation

Also included in the module are additional Global objects to assist animation:

Tweener
PathTweener

The source code can be downloaded from CangoAnimation-8v01.js or the minified version at CangoAnimation-8v01-min.js.

Getting Started

The extension requires Cango canvas graphics library Version 19 or later. Save this file along with the CangoAnimation file, then add the following lines to the web page header:

<script src="[directory path/]Cango-19v07-min.js"></script>
<script src="[directory path/]CangoAnimation-8v01-min.js"></script>

Make sure that the CangoAnimation file is loaded after the Cango file, as it adds methods to an existing Cango core. Application code can create Cango instances that they will have the additional methods available.

Animation architecture

Animations may be simply created by calling the Cango animation method. The calling syntax is as follow:

const animId = cgo.animation(initFn, drawFn, pathFn, options);

The 'initFn', is a user defined function that can create objects or apply transforms to the animated object prior to the animation commencing. The 'drawFn' is the function that actually does the rendering of the scene onto the canvas at each frame. The 'pathFn' is a function called at every frame to calculate the new positions, rotations etc. and can also apply transforms to the objects to be rendered. The 'options' parameter is an object which can hold any user defined properties that may be required by the initFn, drawFn or pathFn.

All animations on a canvas are controlled by a master timeline with all animated objects transformed and rendered at each frame even if they are drawn on different layers. The 'pathFn' functions of all animated objects are passed the elapsed time along the same timeline at each frame ensuring the motion of different objects are synchronized. The animation frame rate is set by the 'requestAnimationFrame' utility, the time between frames will not be constant but will generally be around 17 msec (~60 frames/second).

The animation control methods cgo.playAnimation, cgo.pauseAnimation, cgo.stepAnimation, cgo.stopAnimation. These methods may be called on any Cango context from the background canvas or any layer, they all refer to the same methods. The 'playAnimation' and 'stepAnimation' methods follow the same sequence of function calls for each frame for each animation on each layer:

  1. Call the animation's 'pathFn', passing the elapsed time along the timeline for this frame and the 'options' object.
  2. Clear the canvas on which each animation's calling Cango context is defined. This automatic canvas clear is the default behavior, but user code may override this and substitute a manual canvas clearing function within the 'drawFn' by creating a "manualClear" property in the animations 'options' object and setting it 'true'.
  3. Call the animation's 'drawFn' to render the Obj2D or Group onto the canvas. Rendering resets all the transforms and resets any clip mask to the full canvas.
  4. Swap the animation's nextState and currState objects.
  5. Save the time at which the frame was drawn in the 'currState.time' property of each animation.

Cango methods

animation

Syntax:

const animId = cgo.animation(initFn, drawFn, pathFn, options);

Description:

The animation method creates an AnimObj object with properties that may be useful to the initFn, drawFn and pathFn functions which are called in the scope of this AnimObj object.

The initFn is called immediately, it is designed to set up the initial state prior to the animation starting. The 'drawFn' is then called to render the scene in this initial state. Once the animation has been started, the pathFn is called at each frame along the timeline. The pathFn calculates the new movement transforms and can apply them to objects. The 'drawFn' is then called to render the frame. The frame rate is set by the 'requestAnimationFrame' utility will be called after each frame is drawn to draw the next resulting in a frame rate of approximately 60 frames per sec. For animations where the state of the next frame depends on the state properties of the current frame a minimum rate at which the 'pathFn' is called to do this recalculation is advisable. The minimum frame rate is set by the Cango 'minFrameRate' property which is set by the Cango.setPropertyDefault method.

Parameters:

initFn: Function - A function to be called to initialize or create additional properties of nextState (if required) and apply these transforms to the object. When called initFn is passed one parameter, the 'options' object.

drawFn: Function - A function to be called after 'initFn' and 'pathFn' calls to actually renders the animated objects to the canvas. When called drawFn is passed one parameter, the 'options' object.

pathFn: Function - A function to be called to update the object properties to the values to be used when drawing the next frame. When called pathFn is passed two parameters 'time' and the 'options' object. The 'time' will be in 'msecs' and represents the elapsed time along the timeline, the same format as 'this.currState.time so that the elapsed time since the last frame was drawn will be:

var dt = time - this.currState.time;  // in milliseconds

options: Object - An object storing any variables or constants that may be required by the initFn, drawFn or pathFn. The options object is passed as an argument to these functions.

deleteAnimation

Syntax:

cgo.deleteAnimation(animId);

Description:

All animations are paused and the animation whose ID is 'animId' is deleted. This ID value was returned when the animation was created by a call to 'animation' method. The animation's AnimObj is removed from the current array of animations. The frame currently drawn on the canvas will remain frozen. This method may be called on any Cango context on any layer in the stack and will still delete the nominated animation.

Parameters:

animId: String - An ID string returned when the animation was created.

deleteAllAnimations

Syntax:

cgo.deleteAllAnimations();

Description:

Deletes all the animations from the background layer and from any and all canvas layers. All the animations are stopped and animation definitions removed from the current array of animations, all the animated objects will remain frozen where they were last drawn. This method may be called on any Cango context on any layer and will still clear all animations on all layers.

Parameters:

none.

playAnimation

Syntax:

cgo.playAnimation([startTime[, stopTime]]);

Description:

Starts all animations that have been defined for Cango contexts on this canvas or stack of canvases (if overlay canvases have been created). At each frame the elapsed time along the timeline is passed to the pathFn of each animated object so they can apply the movement transforms. By default, the timeline is traversed from time 0, but if a 'startTime' is passed then this will be the time initially passed to the pathFn. The timeline is traversed by drawing a frame and then making a call to 'requestAnimationFrame' to draw the next and so on, the resulting frame rate will be about 60 frames/sec. If a 'stopTime' was passed to the playAnimation method then the animation will stop as soon as the elapsed time since starting equals or exceeds this stopTime.

Parameters:

startTime: Number - Optional time in milliseconds along the timeline where the animation playing will commence.

stopTime: Number - Optional time in milliseconds along the timeline where the animation will cease and the mode will be set to 'stopped'.

pauseAnimation

Syntax:

cgo.pauseAnimation();

Description:

Stops any current animation and saves the elapsed time since the start of the animation. When 'playAnimation' or 'stepAnimation' are called the animation will resume from the current time offset from the start. This differs from the 'stopAnimation' call which forces the animation to return to the start when 'playAnimation' or 'stepAnimation' is called. All Cango instances on any layer on this background canvas use the same timeline so any can make this call and it will have the same effect.

Parameters:

none.

stepAnimation

Syntax:

cgo.stepAnimation();

Description:

If the animation is currently playing this call does nothing. If paused or stopped the animation will request a frame to be drawn on the background canvas and all overlay layers with the elapsed time of the animation advanced by the current value of the Cango.stepTime property. One frame will be drawn and the animation put into the 'paused' state. The value of 'stepTime' may be set by setPropertyDefault, the default value is 50 msec.

Parameters:

none.

stopAnimation

Syntax:

cgo.stopAnimation();

Description:

Stops any current animation and reset the elapsed time into the animation back to 0. When 'playAnimation' or 'stepAnimation' is subsequently called the animation will resume from the beginning of the animation timeline. This differs from the 'pauseAnimation' call which restart the animation from the current elapsed time along the animation timeline. All Cango instances on any layer on this background canvas use the same timeline so any can make this call and it will have the same effect.

Parameters:

none

redrawAnimation

Syntax:

cgo.redrawAnimation();

Description:

The last frame of all the animations on the background canvas timeline are redrawn at the state saved in the 'currState' object. If the animation is currently playing, then the animation is paused and the last frame to be drawn is redrawn and the animation resumes playing. This method is particularly useful when used as the drawFn of the Zoom utility. If Zoom utility has been added to zoom or pan an animation the redrawn frame will be redrawn with the world coordinate scaling and offsets applied.

Parameters:

none.

AnimObj object

To implement this animation model, the Cango animation method creates a object of type AnimObj which encapsulates references to the Cango context that created the animation and other properties that may be useful to the 'initFn', 'drawFn' and 'pathFn'. These functions are called in the scope of the AnimObj object ie. within these functions 'this' will refer to the AnimObj object so all its properties are readily available.

The 'pathFn' may be of a type that needs to refer to the values used to drawn the frame currently on the screen and the time it was drawn, to calculate values for the next frame. To assist in such situations, Cango animation provides the currState and nextState objects. All the newly calculated values that the pathFn will need to create the transforms for the next frame should be stored in the object this.nextState. After playAnimation or stepAnimation renders the object to the canvas it swaps this.currState and this.nextState objects so that the 'as rendered' properties are always available in this.currState. The pathFn should treat this.currState properties as 'read-only' using the values to make calculations, write these to this.nextState then apply the transforms to the object.

AnimObj properties

PropertyTypeDescription
gc Cango objectThe graphics context that will render the object onto the canvas at each frame along the timeline. The pathFn can make use of this graphics context to call Cango methods such as setPropertyDefault etc.
nextState ObjectA JavaScript object provided to hold all the user defined properties that will be changed at for each frame. The 'pathFn' updates the nextState property values prior to 'obj' being rendered. Once the frame is rendered 'nextState' is swapped with the 'currState' object so the 'as rendered' values of the property values are available to the pathFn as a reference when next called. The pathFn will then overwrite the nextState values with new values for the next frame and so on.
currState ObjectA JavaScript object to hold a copy of the 'nextState'. This object should be considered read-only as it will be swapped with the 'nextState' object after each frame is rendered. The currState object will always have the property 'time' which holds the time (in msec) at which the frame on the canvas was rendered. All other properties will be user defined 'nextState' properties.
options ObjectAn object that is available to the 'initFn', 'drawFn' and 'pathFn' to hold values useful in setup, drawing or generating the 'nextState'. If the 'pathFn' calls a Tweener.getVal method the 'options' object can be used to hold the array of keyframe values to be interpolated.
If the animation 'options' object a property manualClear is set 'true' then automatic clearing of the canvas layer will be disabled. If 'manualClear' if 'false' or undefined the canvas will be cleared before the next frame is drawn.

Animation example

Here is the source code for an animation which rotates a Text object through 360 degrees then back again at the same time scaling the object to twice its size and back again. The animation starts after a 1 sec delay then repeats the movement and the delay period indefinitely.

Figure 2. Text object with animated translate and scale.

function orbitDemo(cvsID)
{
  const txt = new Text("Hullo", {
        fillColor:"blue",
        fontSize:8,
        lorg:5}),
      orbitData = {radius:60, va:1.5, ang:0};

  function initTxt(opts)
  {
    txt.translate(opts.radius, 0);
  }

  function drawTxt(opts)
  {
    this.gc.render(txt);
  }

  function orbit(time, opts)
  {
    const dt = time-this.currState.time; // time since last frame

    opts.ang += opts.va*dt/1000;     // constant angular velocity
    if (opts.ang > 2*Math.PI)        // wraparound for angle
    {
      opts.ang -= 2*Math.PI;
    }
    txt.scale(2.5+ Math.cos(opts.ang));
    txt.translate(opts.radius*Math.cos(opts.ang),
                  opts.radius*Math.sin(opts.ang));
  }

  const g = new Cango(cvsID);
  g.setWorldCoordsRHC(-100, -100, 200);

  g.animation(initTxt, drawTxt, orbit, orbitData);
  g.playAnimation();
}

Animation example using 'currState' and 'nextState'

In the animation of a bouncing ball shown below in Fig 4, the ball's position and velocity at each frame depends on its position and velocity at the last frame and the time since the last frame was drawn. Newton's laws of motion will dictate that the ball should continue to move according to its velocity vector, added to this is the acceleration due to gravity and nay collisions with the boundary walls. Saving the state vector in 'nextState' object means that it will be available as 'currState' when the pathFn is called for the next frame. The time a frame is drawn is always maintained in currState.time and the time at which the pathFn is called is passed as a parameter to the pathFn.

Figure 4. Example of animation next frame state calculation based on current frame state.


Here is the source code for the animation shown above.

var bounceGC;           // Cango graphics context

function bouncingBallDemo(cvsID)
{
  const dia = 45,
        reflect = -1,     // bounce off wall else disappear
        coeff = 0.82,     // percentage bounce height
        friction = 0.985, // rolling friction loss/msec
        speed = 1.5,      // units are like worldCoords x axis units/mm
        gravity = -0.0098 * speed, // =9.8m/s/s =0.0098mm/ms/ms =0.0098*speed units/ms/ms
        startX = 120,
        startY = 250;

  function initBall(opts)
  {
    // create any properties you want in nextState, currState will be a clone
    // don't write to currState its just there for reference to what is on the screen
    this.nextState.x = startX;
    this.nextState.y = startY;
    this.nextState.vx = 0;
    this.nextState.vy = 0;
  }

  function drawBall(opts)
  {
    ballGrp.translate(this.nextState.x, this.nextState.y);
    this.gc.render(ballGrp);
  }

  function bouncingPath(time, opts)    // time = time since start of animation
  {
    // 'this' refers to the Animation object (this.gc, this.nextState etc)
    // this.currState is for reference, what is on the screen (don't write to it)
    // after the frame is drawn in nextState, nextState and currState are swapped
    if (time == 0)   // generate random launch angle for each reset
    {
      // restart at 0.4 m/sec initial velocity and at a random angle
      // velocity in 2m/s = 2 mm/ms = 2mm/ms * units/mm = 2*speed
      const vel = 2*speed;    // x units/ms
      const startAngle = 30+120*Math.random();
      this.nextState.x = startX;           // put ball back at the start point
      this.nextState.y = startY;
      this.nextState.vx = vel * Math.cos(startAngle * Math.PI / 180);
      this.nextState.vy = vel * Math.sin(startAngle * Math.PI / 180);

      return;     // this is the state to get drawn at start
    }
    // calculate the new position and velocity
    const timeInt = time - this.currState.time;   // time since last draw
    // v = u + at
    let yVel = this.currState.vy + gravity * timeInt;    // accelerating due to gravity
    let xVel = this.currState.vx;                    // constant
    let x = this.currState.x + xVel*timeInt;
    let y = this.currState.y + yVel*timeInt + 0.5*gravity * timeInt * timeInt;
      // now check for hitting the walls
    if (x > opts.rightWall - opts.radius)
    {
      x = opts.rightWall - opts.radius;
      xVel *= reflect*coeff;    // lossy reflection next step
    }
    if (x < opts.leftWall + opts.radius)
    {
      x = opts.leftWall + opts.radius;
      xVel *= reflect*coeff;    // lossy reflection next step
    }
    if (y > opts.topWall - opts.radius)
    {
      y = opts.topWall - opts.radius;
      yVel *= reflect*coeff;    // lossy reflection next step
    }
    if (y < opts.bottomWall + opts.radius)  // this is always true after yVel become small
    {
      y = opts.bottomWall + opts.radius;
      // calc velocity at the floor   (v^2 = u^2 + 2*g*s)
      const s = this.currState.y - (opts.bottomWall + opts.radius);     // pre bounce
      const u = this.currState.vy;
      yVel = -Math.sqrt(u*u - 2*gravity*s);
      yVel *= reflect*coeff;  // lossy reflection next step
      // after bouncing phase this is rolling friction
      xVel *= friction;
    }
    this.nextState.x = x;
    this.nextState.y = y;
    this.nextState.vx = xVel;
    this.nextState.vy = yVel;
  }

  const shadows = sphereShading(dia, 255, 0, 0);
  const ball = new Shape(circle(dia), {fillColor:shadows.base} );
  const shade = new Shape(circle(dia), {fillColor:shadows.shadow});
  const ballHilite = new Shape(circle(dia), {fillColor:shadows.hilite});
  const ballGrp = new Group(ball, shade, ballHilite);

  bounceGC = new Cango(cvsID);
  bounceGC.setWorldCoordsRHC();      // use raw pixels

  bounceGC.animation(initBall, drawBall, bouncingPath, {radius:dia/2,
                                                        leftWall:0,
                                                        rightWall:bounceGC.rawWidth,
                                                        topWall:bounceGC.rawHeight,
                                                        bottomWall:0});
}

Tweener interpolation utility

When writing a Cango animation 'pathFn' functions, the aim is to generate values of position, scale, rotation etc or some style property that can be set for an object being animated. New values need to be generated for each frame so the 'pathFn' is called prior to each frame. If the property is specified as an array of key values and key times then the next frame values can be obtained by interpolating between these keyframe values. The Tweener object is provided to simplify this task. A Tweener holds the basic parameters of a timeline and has just one method: getVal which will do the interpolation calculations based on key frame values and the time along the timeline.

Tweener

Syntax:

var twnr = new Tweener(delay, dur, loopStr);

Description:

This utility is provided to simplify interpolation between elements in an array of key values. Creating a Tweener object sets up a timeline of length 'dur' milliseconds which optional 'delay' to the start of the animation and whether the animation repeats specified by the 'loop' parameter.

Parameters:

delay:Number - A time in milliseconds that must be exceeded before interpolating key values begins. When a Cango animation is started by a call to the 'playAnimation' method times starting at 0 msec are passed to path functions.

dur:Number - The duration of the animation starting after 'delay' milliseconds and lasting 'dur' milliseconds. Interpolated values will be returned for time between delay msec and delay+dur msec.

loopStr:String - The 'loopStr' parameter can take two values that will cause animation looping: 'loop' and 'loopAll'. After the initial delay (if delay is non-zero) 'loop' will cause the animated sequence that is 'dur' msec long to be repeated without repeating the delay. 'loopAll' will cause the delay and the animation to repeat, so the repeat interval will be delay+dur msec long. Either value will repeat its sequence indefinitely or until 'stopAnimation' or 'pauseAnimation' stops more calls being made to animation path functions. If the delay = 0 there is no difference in behavior between 'loop' and 'loopAll'.

Tweener method

getVal

Syntax:

var val = twnr.getVal(time, keyValues[, keyTimes]);

Description:

The getVal method is designed to be used in an animation pathFn which will be called immediately prior to each animation frame being rendered. It returns a value for some property at time 'time' along a timeline of duration twnr.dur by interpolating between elements in an array of key values. The keyframe values are specified in the keyValues array.

The key frame times corresponding to the key values may be passed to 'getVal' in a separate keyTimes array. If no 'keyTimes' array is passed to the 'getVal' method then the key values are assumed to be equally spaced over the 'dur' time, the first key value represents the property at the beginning of the Tweener timeline 'dur' period and the last key value is the value at the end of 'dur' period.

Parameters:

time: Number - Elapsed time along the Tweener timeline, measured in milliseconds when getVal is called. The value starts at 0 and continues incrementing until Cango.stopAnimation is called, which will reset the elapsed time to 0. The 'getVal' method will not start interpolating until the time exceeds the Tweener 'delay' time. It then commences interpolating the value of the properties based on the time elapsed since starting as a percentage of the 'duration' parameter. The animation will last for 'dur' milliseconds. It will continue to return the last keyValue for all times exceeding delay+dur unless the timeline has looping enabled.

keyValues: Number or Array of Numbers - A single number will represent the static value for the entire animation. If 'keyValues' is an array then its elements represent the key values in the animation. If the array of values has 2 or more elements, the first will be the initial value returned after time 'delay' milliseconds. The last value will be the value at the finish of the animation after time interval 'delay+dur'.

keyTimes: Array of Numbers - An array holding the keyframe times corresponding to the keyValues. The keyTimes array must have the same number of elements as its matching values array. Time values are specified as a percentage of the Tweener.dur property so the values are limited to the range 0 to 100. If keyTimes is undefined then the key values are assumed to be equally spaced over the 'dur' time.

Returns: Number.

Tweener example

Here is the source code for an animation which changes the fillColor a circle from red to green then blue then back to red. The animation lasts 3 seconds and repeats the cycle indefinitely.

Figure 3. Shape object with animated "fillColor" property.

function animColor(cvsID)
{
  const disc = new Shape(circle(50)),
        colData = { r:[255, 0,   0, 255],
                    g:[0, 200,   0,   0],
                    b:[0,   0, 255,   0] },
        colTwnr = new Tweener(0, 3000, "loop");

  function drawDisc(opts)
  {
    this.gc.render(disc);
  }

  function changeColor(time, opts)
  {
    const rVal = Math.round(colTwnr.getVal(time, opts.r)),
          gVal = Math.round(colTwnr.getVal(time, opts.g)),
          bVal = Math.round(colTwnr.getVal(time, opts.b));

    disc.setProperty("fillColor", "rgb("+rVal+","+gVal+","+bVal+")");
  }

  const g = new Cango(cvsID);
  g.setWorldCoordsRHC(-50, -50, 100);
  g.animation(null, drawDisc, changeColor, colData);

  g.playAnimation();
}

Animation along a Path

CangoAnimation Version 8 introduces PathTweener object to simplify the making animations of objects following a Path. When writing a Cango animation 'pathFn' functions that aim to follow a track defined by a Path object. New values of the objects position along the path must be generated for each frame. The objects speed may vary so that equal intervals in time won't necessarily correspond to equal distances along the path. The PathTweener object is provided to simplify this task. A PathTweener holds the basic parameters of a timeline and has just one method:
getPos which will do the interpolation calculations based on Path object and key position values values and the time along the timeline.

PathTweener

Syntax:

var ttnr = new PathTweener(pth, delay, dur, loopStr);

Description:

This utility is provided to simplify calculation of the coordinates of positions along a path defined by the 'pth' Path object. Creating a PathTweener object sets up a timeline of length 'dur' milliseconds which optional 'delay' to the start of the animation and whether the animation repeats specified by the 'loop' parameter.

Parameters:

pth: Path - A pre-defined Cango Path object defining the path to be follow by the animated object.

delay:Number - A time in milliseconds that must be exceeded before interpolating key values begins. When a Cango animation is started by a call to the 'playAnimation' method times starting at 0 msec are passed to path functions.

dur:Number - The duration of the animation starting after 'delay' milliseconds and lasting 'dur' milliseconds. Interpolated values will be returned for time between delay msec and delay+dur msec.

loopStr:String - The 'loopStr' parameter can take two values that will cause animation looping: 'loop' and 'loopAll'. After the initial delay (if delay is non-zero) 'loop' will cause the animated sequence that is 'dur' msec long to be repeated without repeating the delay. 'loopAll' will cause the delay and the animation to repeat, so the repeat interval will be delay+dur msec long. Either value will repeat its sequence indefinitely or until 'stopAnimation' or 'pauseAnimation' stops more calls being made to animation path functions. If the delay = 0 there is no difference in behavior between 'loop' and 'loopAll'.

PathTweener method

getPos

Syntax:

var pos = twnr.getPos(time, keyDists[, keyTimes]);

Description:

The getPos method is designed to be used in an animation pathFn which will be called for each animation frame. It returns an object containing the properties "x", "y" and "gradient" representing the objects position at time 'time' along a timeline. The 'keyDists' argument provides an array of distances along the path expressed as percentages of the total path length. The 'keyTimes' array holds the times at which the object should be at the corresponding distance.

Parameters:

time: Number - Elapsed time along the Tweener timeline, measured in milliseconds when getPos is called. The value starts at 0 and continues incrementing until Cango.stopAnimation is called, which will reset the elapsed time to 0. The 'getPos' method will not start interpolating until the time exceeds the Tweener 'delay' time. It then commences interpolating the value of the properties based on the time elapsed since starting as a percentage of the 'duration' parameter. The animation will last for 'dur' milliseconds. It will continue to return the last 'keyDists' value for all times exceeding delay+dur unless the timeline has looping enabled.

keyDists: Array (of Number) or Number - array elements represent the key distances along the path.
Note: A single number is allowed for keyDists which will represent the static position for the entire animation.

keyTimes: Array of Numbers - An array holding the keyframe times corresponding to the 'keyDists'. The keyTimes values are specified as a percentage of the PathTweener.dur property. If keyTimes is undefined then the key values are assumed to be equally spaced over the 'dur' time, the first key value represents the property at the beginning of the Tweener timeline 'dur' period and the last key value is the value at the end of 'dur' period.

Returns:

An object {x: , y: , gradient: }.

PathTweener getPos example

Here is the source code for a animated rocket following a path representing the flight path. The animation lasts 5 seconds and can be repeated by clicking "PLAY".

function rocketDemo(cvsID)
{
  const rawTrack = "m 32.6,235 
                    c -3.2,-96 -2.4,-112 13.5,-137.5 15.9,-25.7 64.9,-40.8 81.9,-6.1 \
                      17,35.6 -9,72.6 -28.6,75.6 -19.9,8 -58,1 -49.3,-37 \
                      7.5,-33 31.5,-42.4 58.9,-34.7 26,7.7 44,31.7 56,50.7 \
                      14,21 28,93 28,93";
  const trkData = segment(rawTrack).translate(-32.6,-235).scale(1,-1);

  const trk = new Path(trkData, {strokeColor:'green', dashed:[11,5]});
  const rocket = makeRocket();
  const flame = makeFlame();

  const pathConfig = {rocketGrp: rocket,
                      distances: [0, 3, 8, 30, 100],
                      delay: 0,
                      duration: 5000,
                      loop:'noloop' };
  const flameConfig = {flameGrp: flame,
                      sizes: [0, 0.8, 0.8, 1.2, 1.2, 0],
                      sizeTimes: [0, 1, 46, 50, 92, 93]};

  const ttwr = new PathTweener(trk, pathConfig.delay, pathConfig.duration, pathConfig.loop);
  const vtwnr = new Tweener(pathConfig.delay, pathConfig.duration, pathConfig.loop)

  function makeRocket(scale=1)
  {
    const fin1 = "M -3.476 5.543 L -7.641 8.482 C -8.142 8.835 -8.773 8.95 -9.367 8.799 \
                  L -14.989 7.403 C -15.136 7.386 -15.236 7.245 -15.204 7.1 \
                  C -15.193 7.056 -15.171 7.015 -15.139 6.982 L -11.065 2.52 Z";
    const path3 = new Shape(fin1, {fillColor:"teal"});

    const fin2 = "M -3.417 -5.608 L -7.507 -8.604 C -8.005 -8.962 -8.633 -9.086 -9.229 -8.941 \
                  L -14.882 -7.62 C -15.068 -7.576 -15.138 -7.348 -15.008 -7.208 \
                  L -10.98 -2.683 Z";
    const path4 = new Shape(fin2, {fillColor:"teal"});

    const hull = "M 12.63 0.115 C 8.52 -4.974 -4.529 -7.835 -10.747 -3.763 \
                  C -10.93 -3.653 -11.048 -3.46 -11.062 -3.247 \
                  L -11.079 3.048 C -11.079 3.264 -10.972 3.466 -10.793 3.586 \
                  C -4.656 7.765 8.415 5.114 12.63 0.115 Z";
    const path5 = new Shape(hull, {fillColor:"grey", lineWidth:2});

    const nose = "M 12.63 0.115 C 10.667 -1.98 8.174 -3.505 5.414 -4.297 \
                  C 5.513 -5.922 5.355 4.385 5.355 4.385 \
                  C 8.12 3.642 10.633 2.167 12.63 0.115 Z";
    const path6 = new Shape(nose, {fillColor:"teal", lineWidth:2});

    const path7 = new Shape(circle(5), {fillColor:"black", lineWidth:2});

    return new Group(path3, path4, path5, path6, path7);
  }

  function makeFlame(scale=1)
  {
    const outFlame  =  "M 0.118 -0.137 C 0.12 -1.972 -1.366 -3.463 -3.201 -3.466 \
                        C -7.983 -3.499 -11.114 -0.156 -17.582 -0.194 \
                        C -11.114 -0.156 -7.996 3.174 -3.212 3.182 \
                        C -1.376 3.185 0.114 1.699 0.118 -0.137 Z";
    const path1 = new Shape(outFlame, {fillColor:"orange"});

    const inFlame =  "M 0.118 -0.137 C 0.111 -1.362 -0.876 -2.357 -2.101 -2.374 \
                      C -5.298 -2.389 -7.411 -0.154 -11.715 -0.179 \
                      C -7.387 -0.151 -5.317 2.067 -2.12 2.082 \
                      C -0.894 2.075 0.101 1.089 0.118 -0.137 Z";
    const path2 = new Shape(inFlame, {fillColor:"yellow"});

    return new Group(path1, path2);
  }

  function initRocket(opts)
  {
    // draw the path
    gc.render(pth);

    const pos = ttwr.getPos(0, pathConfig.distances); 
    const size = vtwnr.getVal(0, flameConfig.sizes);

    const x = pos.x;
    const y = pos.y;
    const angle = 180*pos.gradient/Math.PI;

    flame.scale(size);
    flame.translate(-11, 0);
    flame.rotate(angle);
    flame.translate(x, y);
    rocket.rotate(angle);
    rocket.translate(x, y);
  }

  function rocketPathFn(time, opts)
  {
    const pos = ttwr.getPos(time, pathConfig.distances);
    const size = vtwnr.getVal(time, flameConfig.sizes, flameConfig.sizeTimes);

    const x = pos.x;
    const y = pos.y;
    const angle = 180*pos.gradient/Math.PI;

    flame.scale(size);
    flame.translate(-11, 0);
    flame.rotate(angle);
    flame.translate(x, y);
    rocket.rotate(angle);
    rocket.translate(x, y);
  }

  function drawRocket(opts)
  {
    this.gc.render(rocket);
    this.gc.render(flame);
  }

  gc = new Cango(cvsID);
  gc.gridboxPadding(7, 10, 0, 0);
  gc.setWorldCoordsRHC(0, 3, 180); // square pixels

  const gL1 = new Cango(gc.createLayer());
  gL1.dupCtx(gc);

  gL1.animation(initRocket, drawRocket, rocketPathFn, pathConfig);
}