Promises: Intro to SVG animation
Promises and Introduction to SVG animation
Using Promises
JavaScript was designed to directly manipulate the contents of a web page. Because of this, JavaScript executes asynchronously. Unlike some other programming languages, like Java, JavaScript uses only a single thread of execution. So, you can’t have multiple threads of executing running at the same time with JavaScript. If JavaScript ran synchronously, any task would block execution of the rest of the program. So, if you have task that takes more than a second or so, it will appear as though the page has become "frozen" or unresponsive. Executing in an asynchronous fashion allows one task to start and execution will jump to the next task in the program. That way, the page will still be able to interact with the user. This is a good thing for web pages in general. But, what happens when the program is performing a task like obtaining data over a network. Suppose that your program is supposed to fill out a table or perform some calculation with that data. If the data has not yet returned, the table will be empty or the calculation will be done incorrectly. For this kind of situation, something must be done to temporarily halt the program execution until the data returns.
Here are a few links to background information on using Promises:
Callback functions, the old way
Prior to the ECMAScript 2015 (ES6) specification, the way to handle this situation where the program needs to wait until some event occurs before proceeding, was to use a callback function. A callback function is a function that is not called directly by the code, but instead is called when some designated event occurs. So, if your program needed some data to draw a table, then a callback function would be used. When the data is returned (from a network call), that event will cause the callback function to be called. That callback function could then render the table or perform the calculation that needed that data.
Callback functions are fine to use, if there is only one process that needs to be carried out when an event occurs. But, it is common to have more than one process that is triggered by an event taking place. In this kind of case, you would need to use nested callback functions. Here is an example of such a situation.
A user tries to login to some application. When authenticated (first event), the program needs to fetch the records associated with that user. When this data is fetched (second event), the program displays these records in a table. Consider the diagram below:
There are two important events going on here. The first event is the user is authenticated. The second event is that the user records are returned. I think that you can see that there would be a problem rendering the table if the program tries to do this before the user records are returned. Each time a request is made to a server, there is a finite amount of time before a response is returned. In addition, the response might be some sort of error, instead of the result we want. If you handle this with callback functions, the pseudocode might look like this:
try {
response = check_login(username, password)
data = get_user_data(response.id)
try {
render_table(data)
}
catch (error) {
data records not returned.
}
}
catch (error) {
user not authenticated
}
This is not an actual program. Rather it is pseudocode that programmers sometimes use to describe the instructions the code must carry out. Lines 1-10 represents what is called a try block. In a try block the computer attempts to execute the instructions. But, if an error occurs on line 2 or line 3, the execution goes to the catch block on lines 11-13. If line 2 and line execute without any errors, then execution goes to the inner try block on lines 4-6. In the inner try block the program will attempt to render the table with the user data. But, if an error occurs where the data records are not returned, then execution goes the the catch block on lines 7-9. If you can imagine this, the actual lines of code to carry out these processes could involve multiple lines of code. So, while this pseudocode shows the basic pattern of the code, the actual code could involve more lines and appear to be more complex. Even if you don’t fully understand this pseudocode, you can see that there are two places that have to handle errors. This is because there are two different kind of errors that can occur. One error is the the user fails to login correctly. The other error is that the user exists, but does not have any records in the database that stores user records.
Keeping the code lined up properly and getting the syntax correct when you have nested try and catch blocks can be difficult. Imagine if the overall process involved three or more events. For each event, you need another level of try and catch blocks. This situation is sometimes referred to as "callback hell".
Promises to the rescue
Promises were added to the JavaScript specification to avoid "callback hell". A Promise is set up so that it causes the program to halt execution until a single result is returned. That result can either be a resolve (process returns what it is supposed to) or a reject (process returns an error). Since program execution is halted by the Promise until either the expected result is returned or an error is returned, Promises can be chained logically. So, instead nested try/catch blocks, you just get a chain of Promises to handle a series of events. So, once you can use Promises correctly, this can make it simpler to write code that handles cases where you must halt the program until some event occurs.
An example of using Promises
Rather than look at a strictly made-up example, let’s come up with an example that will help to see how Promises and the setTimeout() function can be used. The setTimeout() function is commonly used in doing things like animation, and can be used any time you want to add a delay in to the execution of a program.
In programming languages that have synchronous execution, there is always some kind of function that can produce a delay in the execution. Those functions are easy to use, and often just look something like:
delay(500);
That would cause a delay of 500 milliseconds, or half of a second. But, there is no such function in JavaScript, because of JavaScript’s asynchronous nature. This is where the setTimeout() function comes into play. The basic syntax of the setTimeout() function looks like this:
setTimeout(some_function, delay);
A key point is that setTimeout() is asynchronous. So, if you use it to call some_function(), subsequent lines of code will execute. It is just that the delay (which is in milliseconds), causes some_function() to be called after that delay.
Suppose that you are calling a function called hello(). The following code example show how to do this correctly:
setTimeout(hello, 500);
This will call the hello() function after 500 milliseconds.
This is an incorrect usage of setTimeout()
setTimeout(hello(), 500);
In this case, the hello() function will be executed immediately without any delay. So, the first argument to the setTimeout() function must be a reference to the function that you want to call after a delay. You don’t want to make the first argument the actual call of that function. Here is an animated gif that shows an interaction with the console that demonstrates this:
Click on the Reload gif button if you want to replay the animation.
If you watch the animation, you can see that we can type a JavaScript function definition right into the console. I defined the hello() function like this:
function hello() {
alert('HELLO');
}
So, running hello() will cause an alert box to pop up with the message 'HELLO'. After defining the hello() function, I typed the following command:
setTimeout(hello, 2000);
Note that the first argument to setTimeout() is a reference to the hello() function. As you can see from the animation, it takes about 2 seconds before the alert is displayed.
Then, I typed the following command:
setTimeout(hello(), 5000);
This time, I incorrectly put in a call to the hello() function instead of a reference to that function. So the alert pops up immediately, not after 5 seconds.
Example with Promises and setTimeout()
To create this example in CodeSandbox, you can connect to your account and click on the Frontend for practicalcompute.cc. In the upper right hand corner, click on Fork and select the name of your project area. Take note of the name that is assigned and go back to your Dashboard. Rename your sandbox to promise_examples.
Edit the package.json file to include the lodash-es dependency. Here is what your package.json should look like:
{
"name": "javascript",
"version": "1.0.0",
"description": "The JavaScript template",
"scripts": {
"start": "parcel ./src/index.html",
"build": "parcel build ./src/index.html"
},
"dependencies": {
"lodash-es": "^4.17.21"
},
"devDependencies": {
"parcel": "^2.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^7.2.0"
},
"keywords": ["css", "javascript"]
}
The new lines are 9-11, where the lodash-es dependency has been added.
To create this example using vite, do the following:
$ cd ~/Documents
$ npx degit takebayashiv-cmd/vite-template-my-project promise_examples
$ cd promise_examples
$ npm install
$ npm install lodash-es
$ npm run dev
Now you can edit the files for this example. Let’s start with index.html. If you are using vite, then open the promises_examples folder with Visual Studio Code.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script type="module" src="index.mjs"></script>
<title>Promises examples</title>
</head>
<body>
<h1>Students table</h1>
</body>
</html>
Line 6 was changed so that the <title> contents was set to "Promises examples". You can also change line 9 to whatever <h1> contents you want. That heading is not important for this example, as most of what we want to observe will be in the Console.
Here is the contents of myclasses.mjs:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
addStudent(student) {
if (this.checkExists(student) === false) {
const id = this.getId();
student.id = id;
this.students.push(student);
}
}
checkExists(mystudent) {
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
getId() {
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The new lines are 11-59, that are used to define the Program class. This Program class will be used to store some Student objects. The manipulation of those Student objects is how we will demonstrate Promises.
Lines 12-15 define the constructor for the Program class. Note that we have an array, this.students, that will be used to store the students in the program.
Lines 17-23 define the addStudent() method. Lines 18-22 define an if statement that executes if the Student is not already stored. Line 18 calls the checkExists() method to see if the student already exists. If the student does not exist yet, line 19 gets an id for that student by calling the getId() method. Then, line 20 assigns that id to the Student object and line 21 adds that student to the end of the this.students array.
Lines 25-36 define the checkExists() method. Lines 26-28 check to see if there are any students already stored. If there are none, then false is returned (as the student could not exist). Lines 28-35 are executed if there is at least one student stored. In that case the for loop defined on lines 29-33 checks to see if student already exists. This makes use of the isEqual() function from the lodash-es package. The lodash-es package contains functions that make it easy to perform certain functions. If you were to write a function equivalent to isEqual(), this would be many lines of code as you would need to see if the objects being compared are not null, then check if the objects are of the same class, then check to see if the objects have the same keys (properties) and then check if all the keys have the same values. You would also need to check if the objects being compared are the same object (in which case object1 === object2). These checks have to be performed in the right order and require a for loop to check if all the keys match and have the same value. Since this is not supposed to be an exercise in writing an equals() method for a class, we use the lodash-es function isEqual().
The nice thing about the isEqual() function from the lodash-es package, is that it can be applied to any JavaScript objects. If we wrote our on equals() method for our Student class, it can only be used to compare two Student objects.
If the for loop on lines 29-33 does not find a match, then false is returned.
Lines 38-52 define the getId() method. Lines 40-42 use an if statement that will set the id to 1 if no students have been stored yet. Otherwise, lines 42-50 define an else clause that use a for loop to find the largest id for all of the stored students. Line 49 adds one to that number to get the new id.
Lines 54-57 define the showAllStudents() method. This is just a simple for loop that iterates over all the stored students.
Here are the modifications to the index.mjs file:
import { Student, Program } from "./myclasses.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
console.log('init called');
const student1 = new Student("Jane", "Doe", "Math");
const student2 = new Student("John", "Doe", "Math");
const mathProgram = new Program("Math Program");
mathProgram.addStudent(student1);
mathProgram.addStudent(student1);
mathProgram.addStudent(student1);
mathProgram.addStudent(student1);
mathProgram.addStudent(student2);
mathProgram.addStudent(student2);
mathProgram.addStudent(student2);
mathProgram.showAllStudents();
}
The new lines are 1 and 12-21. Line 1 imports the Program class from myclasses.mjs. Line 12 creates another student. Line 13 creates a new Program object. Lines 14-20 adds the two student objects to mathProgram multiple times. This is to test if the addStudent() method only adds students that don’t already exist in the program. Line 21 calls showAddStudents() to display all the students in the program.
The following screen shot of the console shows the output from running this app:
As you can see, only two student objects were added to mathProgram. They have also been assigned the expected id values.
Adding a delay to the getId() method
Next, let’s add in some delays using setTimeout() and Promises. Let’s simulate the situation where it takes a finite amount of time, for the getId() method to run. Here is the new code for myclasses.mjs:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
addStudent(student) {
if (this.checkExists(student) === false) {
const id = this.getId();
student.id = id;
this.students.push(student);
}
}
checkExists(mystudent) {
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The new lines are 11-15 and 44-45. Lines 11-15 define the delay() function. This function is not part of the Program class. Since delay() is not exported, delay() is only available inside of myclasses.mjs. Line 11 defines the delay() function to have a parameter called ms. The value of ms will be the amount of milliseconds that the delay will last for. Lines 12-14 call for the return of a new Promise. The function defined inside this Promise is an anonymous function with the parameter resolve. If you recall, resolve is one of the two ways that a Promise can return and end. This anonymous function calls the setTimeout() function passing it a reference to resolve and the delay time. So, the call of setTimeout() should take the value of ms in milliseconds to resolve.
Line 44 makes the getId() method an async method. This means that we can use await to wait for the associated function call to complete. In this case, the amount of delay should be 1000 milliseconds or about 1 second. When we run this new version of the app, we will capture the action in an animated gif, which is shown next:
Click Reload gif to replay the animation.
The page is reloaded, but notice there is no delay. In addition, if you look carefully, the value for the id of each student is Promise. That is how the Console displays a Promise that has not been fulfilled yet. So, why does this happen? Even though the getId() method does actually pause for about a second, the setTimeout() function is still asynchronous.
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
So, what happens is that when getId() is called the first instruction starts the delay process. This does not halt the program. So,look at the addStudent() function:
addStudent(student) {
if (this.checkExists(student) === false) {
const id = this.getId();
student.id = id;
this.students.push(student);
}
}
This means that the line assigning id to student.id occurs right away. Instead of an actual id, we get an unfulfilled Promise. So this is what assigned for the student’s id and that is the value that shows up when we look at the stored student. So, what we need to do is make the addStudent() method an async method, and then await the call to getId(). So, here is the next version of myclasses.mjs:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
async addStudent(student) {
if (this.checkExists(student) === false) {
const id = await this.getId();
student.id = id;
this.students.push(student);
}
}
checkExists(mystudent) {
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The new lines are 23 and 25. Line 23 makes the addStudent() method an async method. This allows, on line 25, for us to use await to wait for the getId() method to return with a proper id.
To make this work, we need to modify index.mjs, so that the init() function is declared as async and that we await the calls to the addStudent() method. Here is the new version of index.mjs:
import { Student, Program } from "./myclasses.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
async function init() {
console.log('init called');
const student1 = new Student("Jane", "Doe", "Math");
const student2 = new Student("John", "Doe", "Math");
const mathProgram = new Program("Math Program");
await mathProgram.addStudent(student1);
await mathProgram.addStudent(student1);
await mathProgram.addStudent(student1);
await mathProgram.addStudent(student1);
await mathProgram.addStudent(student2);
await mathProgram.addStudent(student2);
await mathProgram.addStudent(student2);
mathProgram.showAllStudents();
}
The new lines are 9 and 13-20. Line 9 declares the init() function as async. This allows the use of await to make the execution halt until the Promise being returned is fulfilled. Lines 14-20 adds the await to all of the calls to addStudent().
Watch the following animated gif to see how this change affects the program.
Click Reload gif to replay animation.
As you can see, when we reload the page, there is a noticeable before the students are shown on the console. In addition, now the students have the proper id values.
Adding a delay to the checkExists() method
Let’s add a delay to the checkExists() method. Here is the new code:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
async addStudent(student) {
if (this.checkExists(student) === false) {
const id = await this.getId();
student.id = id;
this.students.push(student);
}
}
async checkExists(mystudent) {
await delay(1000);
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The new line is 46. On that line we add a delay of about one second. If we run the program now, there is no output beyond the 'init called' that runs at the very beginning. When this kind of situation occurs, and you cannot see any output, you need to add output to shed light on the problem. Since the checkExists() method was modified by adding a delay, you need to look at what that kind of delay can cause. Since the only thing that was changed in the checkExists() method is adding that delay, the problem must occur in the method that is calling checkExists(). That means we need to look at the addStudent() method. Here is the next version of myclasses.mjs:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
async addStudent(student) {
const exists = this.checkExists(student);
console.log('exists', exists);
if (exists === false) {
const id = await this.getId();
student.id = id;
this.students.push(student);
}
}
async checkExists(mystudent) {
await delay(1000);
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The lines are 24-26. On line 24 we store the return value from calling the checkExists() method. This is the natural thing to do, since that is the value that could have changed. Line 25 prints out that returned value. This is how we can shed light on the problem. Line 26 has been modified to make use of the returned value that is already stored in exists. The following animated gif shows what happens when the app is run now:
Click on Reload gif to replay the animation.
As you can see, the value of exists shows up right away and is a pending Promise. So, the first thing that should come to mind, is that the app is not waiting for checkExists() to complete. There should be a pause before the value of exists shows up, because of the delay added to the checkExists() method. The next thing that should come to mind is that since the value of exists is a pending Promise, that value can never match false. So, the conditional statement never runs and that means getId() never runs. The student is not given an *id and the student is not added to the this.students array. That is why no further output appears. Actually, the output from displaying all the students is blank as no students exist at the end.
So, the key thing is that in addStudent(), we are not waiting for checkExists() to complete running, so a pending Promise is returned. The value of a pending Promise can never be equal to false, so no further execution takes place. The reason why we see 7 outputs for exists is that in the init() function we are purposely adding each student several times (a total of 7 times).
That analysis tells you that we forgot to add an await to get the return value from checkExists(). So, here is the new version of myclasses.mjs:
import { isEqual } from "lodash-es";
export class Student {
constructor(fName, lName, major) {
this.first_name = fName;
this.last_name = lName;
this.major = major;
}
}
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export class Program {
constructor(programName) {
this.programName = programName;
this.students = [];
}
async addStudent(student) {
const exists = await this.checkExists(student);
console.log('exists', exists);
if (exists === false) {
const id = await this.getId();
student.id = id;
this.students.push(student);
}
}
async checkExists(mystudent) {
await delay(1000);
if (this.students.length === 0) {
return false;
} else {
for (let student of this.students) {
if (isEqual(mystudent, student)) {
return true;
}
}
return false;
}
}
async getId() {
await delay(1000);
let id;
if (this.students.length === 0) {
id = 1;
} else {
let maxId = this.students[0].id;
for (let i = 1; i < this.students.length; i++) {
if (this.students[i].id > maxId) {
maxId = this.students[i].id;
}
}
id = maxId + 1;
}
return id;
}
showAllStudents() {
for (let student of this.students) {
console.log(student);
}
}
}
The new line is 24. The await has been added in. So, now the app waits until checkExists() has completed. Look at the following animated gif to see what happens now:
Click on Reload gif to replay animation.
Now you can see that exists is displayed and has either a value of false if the student does not exist, or true if the student does exist. Notice the pauses. The first value of exists is false. That is because no students exist at that point. Then the next 3 values of exists are true, because inside of init() we try to add that same student three more times. Then, we add the other student so that exists is false, then true two times after that. Finally, the output showing all the stored students shows the correct students with the expected id values.
Using Promises to animate a SVG
Using a Promise to animate a HTML element
Before we animate a SVG object, let’s start by animating a HTML element. Right now in the index.html file, we have a <h1> element. Let’s make that element move using a Promise. We will create a new module in the file animate.mjs. This will contain some functions that will be useful in animating a HTML element. Here is the code for animate.mjs:
export function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
The delay() function here uses a Promise and the setTimeout() function to create a delay that can be awaited
Here is the new version of index.mjs that makes use of this delay() function:
import { delay } from "./animate.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
async function init() {
console.log('init called');
await delay(2000);
console.log('output after delay');
}
The new lines are 1 and 11-12. Line 1 imports the delay() function from animate.mjs. Line 11 calls the delay() function and uses a delay of about 2 seconds. Line 12 is used to show some output after the delay. To see this working, here is an animated gif file:
Click on Reload gif to replay animation.
If you notice, the 'output after delay' message is displayed about 2 seconds after the 'init called' message. Now that we have tested the delay() function, let’s create a simple function that can move a HTML element. For this next version of animate.mjs, we just want to show the basic mechanism for animation using the requestAnimationFrame() function. Here is animate.mjs:
export function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function moveElement(elem, x_dist, duration) {
return new Promise((resolve) => {
let startTime = null;
const step = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
}
let progress = Math.min((timestamp - startTime)/duration, 1);
console.log(progress);
if (progress < 1) {
requestAnimationFrame(step);
}
else {
resolve(); // animation ends
}
}
requestAnimationFrame(step);
});
}
The new lines are 7-26. These lines define our early version of the moveElement() function. This function has been simplified just to give you a taste for how the requestAnimationFrame() function works. Let’s start by talking about the structure of the moveElement() function. Here is the outer Promise that is used to make a function where the calling code can use await to make sure that the animation is completed before starting up execution of the app again.
export function moveElement(elem, x_dist, duration) {
return new Promise((resolve) => {
});
}
So the moveElement() function will return a Promise that can be awaited.
Within the body of this Promise is a function called step(). Here is a function notation that you should be aware of. This is the way functions are often declared:
function step(timestamp) {
}
Another way to define that function is to use this arrow function notation:
const step = (timestamp) => {
}
The step() function is the function that is called over and over until the animation is complete. You need to have a selection statement inside the step() function that will either cause the step() function to be called again, or will end the animation. Within the step() function, there must be some change in a position property to produce the animation effect. In this simple case, we are not animating anything on the web page. So, we are printing the value of the progress variable to show what is changing for each time step() is executed. Line 15 defines progress and it is a way of having a value that starts at 0 and goes up to 1, in a linear fashion. That will produce an even motion when progress is used to change the position property of the object that is being moved. Line 16 simply prints out progress so that you can see the value change, as we are not actually animating the HTML element in this version.
Lines 12-14 will set startTime to be the value of timestamp. The timestamp value is the number of seconds since a preset date and time. Lines 12-14 assure that startTime will have the same value throughout all the runs of the step() function. On the other hand, timestamp keeps updating every second. So, the expression, (timestamp - startTime) increases with each second. If we don’t divide by duration, then the value of progress will reach 1, and the animation process will end. So, by dividing by duration, we can make progress change over the value of duration.
Lines 17-19 check to see if the value of progress is still less than 1. If progress is less than 1, the requestAnimationFrame() function causes step() to run again. Once progress reaches 1, requestAnimationFrame() is not called and the animation ends. Line 21 calls resolve() to also complete the Promise.
Line 24 makes the initial call of the step() function through requestAnimationFrame(). This is what starts the animation process going.
We need to change index.mjs to make use of the moveElement() function.
The following shows the app running in an animated gif. You can see the value of progress updating until the animation is finished.
Click on Reload gif to replay animation.
As you can see, the value of progress increases over the 1 second duration until it reaches 1. So, we can use the value of progress to change the coordinates of some element. So, now let’s animate the <h1> element on the page. Here is the new version of animate.mjs that actually moves a HTML element:
export function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
export function moveElement(elem, x_dist, duration) {
return new Promise((resolve) => {
let startTime = null;
const bounds = elem.getBoundingClientRect();
let startX = Number(bounds.x);
const endX = startX + x_dist;
const step = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
}
let progress = Math.min((timestamp - startTime)/duration, 1);
startX = startX + (endX - startX) * progress;
elem.style.transform = `translate(${startX}px, 0)`;
if (progress < 1) {
requestAnimationFrame(step);
}
else {
resolve(); // animation ends
}
}
requestAnimationFrame(step);
});
}
The lines are 10-12 and 20-31. Most HTML elements don’t have explicit (x, y) coordinates. But, we can use the getBoundingClientRect() function that returns a rectangular set of bounds for any HTML element. So, on line 10 we get that bounding rectangle, and on line 11 we use the "x" property of those bounds to get our startX value. Line 12 uses the x_dist parameter to calculate the endX value. Line 20 calculates a new value for startX that has a larger value because (endX - startX)*progress is added on. Line 21 sets the *transform* property of the style attribute for the <h1> element to translate(${startX}, 0). This will move the element to the new startX value. The y-value remains unchanged. So, each time the step() function is run, the value of progress increases, so the element will move closer and closer to the endX value.
To make use of these changes, the index.mjs file needs to be modified. Here is the new version of index.mjs:
import { delay, moveElement } from "./animate.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
async function init() {
console.log('init called');
const h1 = document.getElementsByTagName('h1')[0];
console.log(h1);
await moveElement(h1, 300, 2000);
console.log('animation finished');
}
The new lines are 11-14. Line 11 gets a reference to the <h1> element. Note that the getElementsByTagName() function returns an array, as there can be more than one element with the same tag name. So, on line 11, we need to specify the array index. This is why there is [0] at the end of that instruction. Line 12 just prints the <h1> element to the console. Line 13 calls the moveElement() function. Note that we use await to wait for that Promise to complete. The call to moveElement() specifies the element to move (h1), the distance to move (300) and the duration (2000). You can experiment with these values for the distance to move and the duration to see how this affects the animation.
With those changes to animate.mjs and index.mjs we can run the app. The following animated gif shows what happens:
Click on Reload gif to replay animation.
When the animated gif starts, the <h1> element is already moved over. Then, when the reload button is clicked you see that <h1> element move across the screen. After the Promise is completed, the 'animation finished' message is displayed in the Console.
So, we can animate HTML elements using this method. We just need to use the getBoundingClientRect() function to obtain the position of that HTML element. Scalable Vector Graphics (SVG) elements are special elements that have the position information as part of their properties. So, we don’t need to use getBoundingClientRect() to animate SVG objects.
Animating a SVG element
As mentioned above, SVG objects are special elements because their properties include position information. To create a SVG object, let’s make use of another module class by creating the file node.mjs. Here are the starting contents for node.mjs:
export class Node {
constructor(x, y, width, height, label, id) {
this.svgNS = "http://www.w3.org/2000/svg";
this.g = document.createElementNS(this.svgNS, "g");
this.g.id = id;
this.g.setAttribute("transform", `translate(${x}, ${y})`);
this.g.setAttribute("data-x", x);
this.g.setAttribute("data-y", y);
this.rect = document.createElementNS(this.svgNS, "rect");
this.rect.setAttribute("x", 0);
this.rect.setAttribute("y", 0);
this.rect.setAttribute("width", width);
this.rect.setAttribute("height", height);
this.rect.setAttribute("fill", "#eeeeee");
this.rect.setAttribute("stroke", "black");
this.rect.setAttribute("stroke-width", 2);
this.text = document.createElementNS(this.svgNS, "text");
this.text.setAttribute("x", width/2);
this.text.setAttribute("y", height/2);
this.text.setAttribute("text-anchor", "middle");
this.text.setAttribute("dominant-baseline", "middle");
this.text.setAttribute("fill", "black");
this.text.textContent = label;
this.g.appendChild(this.rect);
this.g.appendChild(this.text);
}
draw(svg_area) {
svg_area.appendChild(this.g);
}
}
The constructor for the Node class contains a relatively large amount of lines. This is because the Node objects are somewhat complex. On line 2, we see that to construct a Node object, we need to specify the (x, y) coordinates of the Node, the width and height (of the enclosed rectangle), the label (the text that is used as the label for the Node) and an id.
Line 3 defines the SVG namespace. This namespace is used for every SVG object that we create. Unlike the regular HTML elements, a SVG object is created using document.createElementNS(). Regular HTML elements are created using document.createElement(). Line 5 creates a group using createElementNS(). Note that this uses the namespace we defined on line 3. The SVG specification allows for SVG objects to be grouped. The name used for such a group is g.
Line 6 assigns the group an id. We will be grouping two SVG elements: a rect element and a text element. So, it makes sense to assign the id to the group, since we can reference the rect and/or text elements once we know the group.
Lines 7-9 assign important properties to the group. Line 7 makes it so that the group’s transform property is set using:
this.g.setAttribute("transform", `translate(${x}, ${y})`);
Note the back ticks (` `) used so that variable interpolation is used. Here ${x} refers to the variable x which is passed as the first parameter of the constructor. Likewise, ${y} refers to the variable y which is the second parameter of the constructor. This is setting the initial position of the group to have the (x, y) values set in the constructor. Without doing this, the initial position of the group would be (0, 0). That would cause problems when animating the group, as there would be an offset between (0, 0) and the actual (x, y) coordinates. So, this is a very important step.
Lines 8 and 9 create the attributes data-x and data-y. These attributes will be used to keep track of the group’s position when the group is animated.
Lines 11-18 set up the rect SVG object that will be part of the group. Note on line 11, the use of createElementNS() and the namespace. Lines 12 and 13 set the x and y coordinates of the rect object with respect to the group. This means that the rect and the group, g, have the same coordinates. This makes sense because both the group and the rect use the upper left corner coordinates as their position. Lines 14 and 15 just set the width and height of the rect to the values passed during construction. Line 16 sets the fill color of the rectangle to #eeeeee, which is a light gray color. Line 17 sets the stroke color (the outline of the rectangle) to black, and uses a stroke-width of 2 pixels to make this outline more visible.
Lines 20-26 set up the text SVG object that is the other part of the group. Note on line 20, the use of createElementNS() and the namespace. Lines 21 and 22 set the x and y coordinates of the text object with respect to the group. Since the group’s coordinates specify the upper left corner, the text coordinates must be moved to the center of the rect object. In this way, lines 23 and 24 can make it so that the label is centered inside the rect object. Line 25 sets the text color to be black. Finally, line 26 sets the textContent of text to be the label set during construction of the Node.
Line 28 places the rect object inside the group, g. Line 29 places the text object inside the group. The order is important to remember as the group uses an array to store the grouped SVG objects. So g[0] refers to the rect object and g[1] refers to the text object.
Lines 32-34 define the draw() method. All this does is add the group to the passed svg_area. The parameter svg_area is a reference to the SVG container element defined for the web page.
We can modify index.html to set up the SVG container element for the web page. Here is the new version of index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script type="module" src="index.mjs"></script>
<title>Promises examples</title>
</head>
<body>
<h1>Animating Node objects</h1>
<svg
id="svg_area"
xmlns="http://www.w3.org/2000/svg"
width="800"
height="200"
></svg>
</body>
</html>
The new lines are 9 and 10-15. Line 9 changes the contents of the <h1> element to "Animating Node objects". lines 10-15 set up the SVG container element. Line 11 sets the id attribute. Line 12 sets the namespace. Line 13 and 14 set the container to have a width of 800 pixels and a height of 200 pixels.
Next, we modify index.mjs to display some Node objects:
import { delay } from "./animate.mjs";
import { Node } from "./node.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
globalThis.svg_area = document.getElementById("svg_area");
async function init() {
console.log('init called');
const nd1 = new Node(30, 30, 30, 25, '12', 0);
nd1.draw(svg_area);
}
The new lines are 1-2, 10 and 14-15. Line 1 imports the delay() function from animate.mjs. Line 2 imports the Node class from node.mjs. Line 10 sets up a global variable so that svg_area can be used anywhere inside of index.mjs. Line 14 constructs a Node object located at (30, 30). Line 15 draws that Node into the SVG container area.
Here is a screen shot showing the Node.
Now that we can draw Node objects, let’s work on animating that Node. We will do this by adding a method called moveTo() for the Node class. Before we do that, let’s review what this method should look like:
moveTo(endX, endY, duration) {
return new Promise((resolve) => {
// initialization before animation
const step = (timestamp) => {
// set up the progress variable
// calculate the new x and y values
if (progress < 1) {
requestAnimationFrame(step);
} else {
resolve();
}
}
requestAnimationFrame(step)
});
}
For any animation, we can use this template for moving an object. Here is the new version of node.mjs that implements the moveTo() method:
export class Node {
constructor(x, y, width, height, label, id) {
this.svgNS = "http://www.w3.org/2000/svg";
this.g = document.createElementNS(this.svgNS, "g");
this.g.id = id;
this.g.setAttribute("transform", `translate(${x}, ${y})`);
this.g.setAttribute("data-x", x);
this.g.setAttribute("data-y", y);
this.rect = document.createElementNS(this.svgNS, "rect");
this.rect.setAttribute("x", 0);
this.rect.setAttribute("y", 0);
this.rect.setAttribute("width", width);
this.rect.setAttribute("height", height);
this.rect.setAttribute("fill", "#eeeeee");
this.rect.setAttribute("stroke", "black");
this.rect.setAttribute("stroke-width", 2);
this.text = document.createElementNS(this.svgNS, "text");
this.text.setAttribute("x", width/2);
this.text.setAttribute("y", height/2);
this.text.setAttribute("text-anchor", "middle");
this.text.setAttribute("dominant-baseline", "middle");
this.text.setAttribute("fill", "black");
this.text.textContent = label;
this.g.appendChild(this.rect);
this.g.appendChild(this.text);
}
draw(svg_area) {
svg_area.appendChild(this.g);
}
moveTo(endX, endY, duration) {
return new Promise((resolve) => {
let startTime = null;
const startX = Number(this.g.getAttribute("data-x"));
const startY = Number(this.g.getAttribute("data-y"));
let currentX = startX;
let currentY = startY;
const step = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
}
let progress = Math.min((timestamp - startTime)/duration, 1);
currentX = startX + (endX - startX)*progress;
currentY = startY + (endY - startY)*progress;
this.g.setAttribute("transform", `translate(${currentX}, ${currentY})`);
if (progress < 1) {
requestAnimationFrame(step);
} else {
this.g.setAttribute("data-x", endX);
this.g.setAttribute("data-y", endY);
console.log('this.g', this.g);
resolve(); // animation is done
}
}
requestAnimationFrame(step);
});
}
}
The new lines are 36-66. This may seem like a lot of lines, but if you look back at the template shown above this source code, the lines are not hard to understand.
Lines 36-66 define the moveTo() method. The basic structure of this method starts off with returning a new Promise. That Promise will have some initialiazation steps on lines 38-42. Then, with the step() function, the lines 45-47 will initialize startTime. Line 48 defines the progress variable.
Lines 50 and 51 will get the values of currentX and currentY that we want to move the Node to. Line 53 causes that movement to take place.
Lines 58-60 will update the values for the data-x attribute and data-y attribute respectively. This is so that if the Node is moved again, the correct values for the group’s coordinates are used. Line 60 is just a line to check to see if the coordinates for the group have been updated correctly. These instructions take place before resolving the Promise on line 61.
Next, we modify index.mjs to test out the moveTo() method:
import { delay } from "./animate.mjs";
import { Node } from "./node.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
globalThis.svg_area = document.getElementById("svg_area");
async function init() {
console.log('init called');
const nd1 = new Node(30, 30, 30, 25, '12', 0);
nd1.draw(svg_area);
nd1.moveTo(70, 50, 1000);
}
The new line is line 16. Line 16 calls the moveTo() method and will move the Node to (70, 50). The Promise should resolve in about a second. When it resolves, the group will be printed to the Console.
Here is an animated gif showing how the app runs now.
Click on Reload gif to replay animation.
You can see that the group object is shown on the console after the animation is completed. The new coordinates for the Node are (70, 50).
Moving the node more than once
Let’s modify index.mjs to move the Node several times. We will make the motion pause between each of the moves. Here is the new version of index.mjs:
import { delay } from "./animate.mjs";
import { Node } from "./node.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
globalThis.svg_area = document.getElementById("svg_area");
async function init() {
console.log('init called');
const nd1 = new Node(30, 30, 30, 25, '12', 0);
nd1.draw(svg_area);
await nd1.moveTo(70, 30, 1000);
await delay(1000);
await nd1.moveTo(70, 80, 1000);
await delay(1000);
await nd1.moveTo(30, 30, 1000);
}
The new lines are 16-20. Line 16 moves the Node to (70, 30). Line 17 is not run until the move on line 16 is completed because we used await on line 16. The await on line 17 creates a pause of about 1 second. If you did not use await on line 17, there would be no pause. Line 18 moves the Node to (70, 80). Since we use await on line 18, this move completes before line 19 is run. Line 19 causes a pause of about 1 second. Finally, line 20 moves the Node back to its starting position. You don’t need to use await for line 20, because there are no instructions after that line. But, it is a good practice to use await, as we should do this for any function call that returns a Promise.
The following animated gif shows the app running:
Click on Reload gif to replay animation.
As you can see, the motion of the Node follows the instructions in the init() function of index.mjs.
Moving more than one node, to swap their places
Let’s add another method that can move a Node. But, instead of just moving one Node, let’s make a method that allows a Node to swap places with another node. The basic template for animation is used again. The difference is that because two nodes are being moved, there is more initialization and setup involved. Here is the new version of node.mjs that adds the swap() method.
export class Node {
constructor(x, y, width, height, label, id) {
this.svgNS = "http://www.w3.org/2000/svg";
this.g = document.createElementNS(this.svgNS, "g");
this.g.id = id;
this.g.setAttribute("transform", `translate(${x}, ${y})`);
this.g.setAttribute("data-x", x);
this.g.setAttribute("data-y", y);
this.rect = document.createElementNS(this.svgNS, "rect");
this.rect.setAttribute("x", 0);
this.rect.setAttribute("y", 0);
this.rect.setAttribute("width", width);
this.rect.setAttribute("height", height);
this.rect.setAttribute("fill", "#eeeeee");
this.rect.setAttribute("stroke", "black");
this.rect.setAttribute("stroke-width", 2);
this.text = document.createElementNS(this.svgNS, "text");
this.text.setAttribute("x", width/2);
this.text.setAttribute("y", height/2);
this.text.setAttribute("text-anchor", "middle");
this.text.setAttribute("dominant-baseline", "middle");
this.text.setAttribute("fill", "black");
this.text.textContent = label;
this.g.appendChild(this.rect);
this.g.appendChild(this.text);
}
draw(svg_area) {
svg_area.appendChild(this.g);
}
moveTo(endX, endY, duration) {
return new Promise((resolve) => {
let startTime = null;
const startX = Number(this.g.getAttribute("data-x"));
const startY = Number(this.g.getAttribute("data-y"));
let currentX = startX;
let currentY = startY;
const step = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
}
let progress = Math.min((timestamp - startTime)/duration, 1);
currentX = startX + (endX - startX)*progress;
currentY = startY + (endY - startY)*progress;
this.g.setAttribute("transform", `translate(${currentX}, ${currentY})`);
if (progress < 1) {
requestAnimationFrame(step);
} else {
this.g.setAttribute("data-x", endX);
this.g.setAttribute("data-y", endY);
console.log('this.g', this.g);
resolve(); // animation is done
}
}
requestAnimationFrame(step);
});
}
swap(nd, duration) {
return new Promise((resolve) => {
let startTime = null;
const x1 = Number(this.g.getAttribute("data-x"));
const y1 = Number(this.g.getAttribute("data-y"));
const x2 = Number(nd.g.getAttribute("data-x"));
const y2 = Number(nd.g.getAttribute("data-y"));
let currentX1 = x1;
let currentY1 = y1;
let currentX2 = x2;
let currentY2 = y2;
const endX1 = x2;
const endY1 = y2;
const endX2 = x1;
const endY2 = y1;
const step = (timestamp) => {
if (startTime === null) {
startTime = timestamp;
}
let progress = Math.min((timestamp - startTime)/duration, 1);
currentX1 = x1 + (x2 - x1)*progress;
currentY1 = y1 + (y2 - y1)*progress;
currentX2 = x2 + (x1 - x2)*progress;
currentY2 = y2 + (y1 - y2)*progress;
this.g.setAttribute("transform", `translate(${currentX1}, ${currentY1})`);
nd.g.setAttribute("transform", `translate(${currentX2}, ${currentY2})`);
if (progress < 1) {
requestAnimationFrame(step);
}
else {
this.g.setAttribute("data-x", x2);
this.g.setAttribute("data-y", y2);
nd.g.setAttribute("data-x", x1);
nd.g.setAttribute("data-y", y1);
resolve();
}
}
requestAnimationFrame(step);
});
}
}
The new lines are 68-111. Just as with the moveTo() method the basic template for animation is used again. Lines 70-82 are initialization steps that take place before the step() function. Lines 70 sets startTime to be null so that it can be set to the value of timestamp as soon as step() is first called. Lines 71-74 obtain the x and y coordinates of the calling Node (this) and the passed Node (nd). Lines 75-78 set up the starting values for the x and y coordinates for both nodes. Lines 79-82 set up the ending coordinates for both nodes.
Lines 84-108 define the step() function. Lines 85-87 initialize startTime. Line 88 calculates the progress value in the same way that this was done for the moveTo() method. Lines 90-93 calculate the next position coordinates for both of the nodes. Lines 95 and 96 set the corresponding group for each Node to move to those calculated coordinates.
Lines 98-107 define an if statement that depends on the value of progress. If progress is less than 1, line 99 calls requestAnimationFrame() to run the next iteration of movement. Once progress is equal to 1, lines 102-105 update the data-x and data-y values of both nodes to the new final positions. Line 106 resolves the Promise to end the animation.
Line 109 starts the animation process off.
Next, we modify index.mjs to place two Node objects in the SVG container area, and then have them swap positions.
import { delay } from "./animate.mjs";
import { Node } from "./node.mjs";
if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
globalThis.svg_area = document.getElementById("svg_area");
async function init() {
console.log('init called');
const nd1 = new Node(30, 30, 30, 25, '12', 0);
const nd2 = new Node(90, 80, 30, 25, '16', 1);
nd1.draw(svg_area);
nd2.draw(svg_area);
await delay(1000);
await nd1.swap(nd2, 1000);
}
The new lines are lines 14-19. Lines 14 and 15 construct two Node objects. Lines 16 and 17 draw those Nodes in the SVG container area. Line 18 causes a pause of about 1 second so that you can see the initial positions of the two Nodes. Finally, line 19 performs the swap. On both line 18 and 19, we use await. This is so that those instructions will complete before the next instruction. It is not needed on line 19 because we don’t have any further instructions. But, it is put there as a reminder that you should use await when calling any function that returns a Promise.
The following animated gif shows the app running:
Click on Reload gif to replay animation.
You can see the pause for about a second before the swap begins. This swap() method will be useful in some of the lessons that follow.
Summary
This lesson introduced JavaScript Promises. We used Promises to introduce delays and also to help control animation of objects:
-
JavaScript execution is asynchronous. Therefore, if an instruction takes some amount of time to complete, subsequent instructions will execute before that first instruction can complete. This can cause errors since processes like fetching data over the network or even from local databases takes time.
-
JavaScript Promises provide a clean way to cause the execution of a program to halt, until a process completes. By declaring functions using async, calls to functions that return a Promise can use await to halt until that process completes.
-
A Promise is pending until it either resolves or is rejected (in the case of error). If you fail to precede a call that returns a promise with await, the returned value will be a pending Promise, which will cause an error.
-
Animating a regular HTML element will require getting the bounds of that element using document.getBoundingClientRect() to obtain some coordinates that can be used to move that element.
-
SVG objects are special HTML elements that have attributes that specify their coordinates. This makes SVG objects very good for use in animation.
-
Using a Promise that makes use of an internal step function is the recommended way to animate SVG objects. This will make use of the requestAnimationFrame() function.