Canvas Clock
This beginner guide walks you through building a clock with the Canvas API.
In this guide you will learn how to:
- Create custom elements for digital and analog clocks
- Use the
canvas
API to draw the hands of the analog clock
The final widget looks like:
See the Pen Canvas Clock (Simple) [Finished] by Bitovi (@bitovi) on CodePen.
The following sections are broken down into the following parts:
- The problem — A description of what the section is trying to accomplish.
- What you need to know — Information about CanJS that is useful for solving the problem.
- How to verify it works - How to make sure the solution works (if it’s not obvious).
- The solution — The solution to the problem.
Setup
START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED:
See the Pen Canvas Clock (Simple) [Starter] by Bitovi (@bitovi) on CodePen.
This CodePen has initial prototype HTML, CSS, and JS to bootstrap a basic CanJS application.
What you need to know
There’s nothing to do in this step. The CodePen is already setup with:
- A basic CanJS setup.
- A
<clock-controls>
custom element that:- updates a
time
property every second - passes the
time
value to<digital-clock>
and<analog-clock>
components that will be defined in future sections.
- updates a
Please read on to understand the setup.
A Basic CanJS Setup
A basic CanJS setup is usually a custom element. In the HTML
tab, you’ll find a <clock-controls>
element. The following code in the JS
tab
defines the behavior of the <clock-controls>
element:
import { Component } from "//unpkg.com/can@5/core.mjs";
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {Default: Date, Type: Date},
connectedCallback() {
setInterval(() => {
this.time = new Date();
}, 1000);
}
},
view: `
<p>{{ this.time }}</p>
<digital-clock time:from="this.time"/>
<analog-clock time:from="this.time"/>
`
});
can-component is used to define the behavior of the <clock-controls>
element. Components are configured with three main properties that define the
behavior of the element:
- tag is used to specify the name of the custom element
(e.g.
"clock-controls"
). - view is used as the HTML content within the custom element; by default, it is a can-stache template.
- ViewModel provides methods and values to the
view
; by default, theViewModel
is a can-define/map/map.
Here, a time
property is defined using the value behavior.
This uses resolve
to set the value of time
to be an instance of a
Date
and then update the value every second to be a new Date
.
Create a digital clock component
The problem
In this section, we will:
- Create a
<digital-clock>
custom element. - Pass the
time
from the<clock-controls>
element to the<digital-clock>
element. - Write out the time in the format:
hh:mm:ss
.
What you need to know
- Use can-component to create a custom element.
- tag is used to specify the name of the custom
element (hint:
"digital-clock"
). - view is used as the HTML content within the custom
element; by default, it is a can-stache template (hint:
"Your {{content}}"
). - ViewModel provides methods and values to the
view
; by default, theViewModel
is a can-define/map/map that defines properties and functions like:ViewModel: { property: Type, // hint -> time: Date method() { return // ... } }
- tag is used to specify the name of the custom
element (hint:
- can-stache can insert the return value from function calls into the page like:
These methods are often functions on the{{method()}}
ViewModel
. - Date has methods that give you details about that date and time:
- Use padStart
to convert a string like
"1"
into"01"
like.padStart(2, "00")
.
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
Component.extend({
tag: "digital-clock",
view: `{{ hh() }}:{{ mm() }}:{{ ss() }}`,
ViewModel: {
time: Date,
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
},
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
},
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
});
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve( new Date() );
}, 1000);
resolve( new Date() );
return () => clearInterval(intervalID);
}
}
},
view: `
<p>{{ this.time }}</p>
<digital-clock time:from="this.time"/>
<analog-clock time:from="this.time"/>
`
});
Draw a circle in the analog clock component
The problem
In this section, we will:
- Create a
<analog-clock>
custom element. - Draw a circle inside the
<canvas/>
element within the<analog-clock>
.
What you need to know
- Use another can-component to define a
<analog-clock>
component. - Define the component’s view to write out a
<canvas>
element (hint:<canvas id="analog" width="255" height="255"></canvas>
). - A component’s ViewModel can be defined as an object
which will be passed to DefineMap.extend
(hint:
ViewModel: {}
). - A viewModel’s connectedCallback will be called when the component is inserted into the page.
- Pass an element reference to the scope, like the following:
<div this:to="key">...</div>
- To get the canvas rendering context
from a
<canvas>
element, usecanvas = canvasElement.getContext("2d")
. - To draw a line (or curve), you generally set different style properties of the rendering context like:
Then you start path with:this.canvas.lineWidth = 4.0 this.canvas.strokeStyle = "#567"
Then make arcs and lines for your path like:this.canvas.beginPath()
Then close the path like:this.canvas.arc(125, 125, 125, 0, Math.PI * 2, true)
Finally, use stroke to actually draw the line:this.canvas.closePath()
this.canvas.stroke();
- The following variables will be useful for coordinates:
this.diameter = 255; this.radius = this.diameter/2 - 5; this.center = this.diameter/2;
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
Component.extend({
tag: "analog-clock",
view: `<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>`,
ViewModel: {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
},
connectedCallback(element) {
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
}
}
});
Component.extend({
tag: "digital-clock",
view: "{{ hh() }}:{{ mm() }}:{{ ss() }}",
ViewModel: {
time: Date,
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
},
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
},
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
});
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve( new Date() );
}, 1000);
resolve( new Date() );
return () => clearInterval(intervalID);
}
}
},
view: `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`
});
Draw the second hand
The problem
In this section, we will:
- Draw the second hand needle when the
time
value changes on theviewModel
. - The needle should be
2
pixels wide, red (#FF0000
), and 85% of the clock’s radius.
What you need to know
this.listenTo can be used in a component’s connectedCallback to listen to changes in the
ViewModel
like:import { Component } from "//unpkg.com/can@5/core.mjs"; Component.extend({ tag: "analog-clock", // ... ViewModel: { connectedCallback() { this.listenTo("time", (event, time) => { // ... }); } } });
Use canvas.moveTo(x1,y1) and canvas.lineTo(x2,y2) to draw a line from one position to another.
To get the needle point to move around a “unit” circle, you’d want to make the following calls given the number of seconds:
0s -> .lineTo(.5,0) 15s -> .lineTo(1,.5) 30s -> .lineTo(.5,1) 45s -> .lineTo(0,.5)
Our friends Math.sin and Math.cos can help here… but they take radians.
Use the following
base60ToRadians
method to convert a number from 0–60 to one between 0 and 2π:// 60 = 2π const base60ToRadians = (base60Number) => 2 * Math.PI * base60Number / 60;
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
Component.extend({
tag: "analog-clock",
view: `<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>`,
ViewModel: {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
},
connectedCallback(element) {
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
Object.assign(this.canvas, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
});
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
const size = radius * 0.85;
const x = center + size * Math.sin(base60ToRadians(seconds));
const y = center + size * -1 * Math.cos(base60ToRadians(seconds));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
});
}
}
});
Component.extend({
tag: "digital-clock",
view: "{{ hh() }}:{{ mm() }}:{{ ss() }}",
ViewModel: {
time: Date,
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
},
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
},
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
});
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve( new Date() );
}, 1000);
resolve( new Date() );
return () => clearInterval(intervalID);
}
}
},
view: `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`
});
Clear the canvas and create a drawNeedle
method
The problem
In this section, we will:
- Clear the canvas before drawing the circle and needle.
- Refactor the needle drawing code into a
drawNeedle(length, base60Distance, styles)
method where:length
is the length in pixels of the needle.base60Distance
is a number between 0–60 representing how far around the clock the needle should be drawn.styles
is an object of canvas context style properties and values like:{ lineWidth: 2.0, strokeStyle: "#FF0000", lineCap: "round" }
What you need to know
- Move the draw circle into the
this.listenTo("time", /* ... */)
event handler so it is redrawn when the time changes. - Use clearRect(x, y, width, height) to clear the canvas.
- Add a function inside the connectedCallback that will have
access to all the variables created above it like:
ViewModel: { // ... drawNeedle(length, base60Distance, styles, center) { // ... } // ... }
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
Component.extend({
tag: "analog-clock",
view: `<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>`,
ViewModel: {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
},
drawNeedle(length, base60Distance, styles, center) {
Object.assign(this.canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
},
connectedCallback(element) {
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
this.drawNeedle(
radius * 0.85,
seconds, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
},
center
);
});
}
}
});
Component.extend({
tag: "digital-clock",
view: "{{ hh() }}:{{ mm() }}:{{ ss() }}",
ViewModel: {
time: Date,
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
},
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
},
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
});
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve( new Date() );
}, 1000);
resolve( new Date() );
return () => clearInterval(intervalID);
}
}
},
view: `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`
});
Draw the minute and hour hand
The problem
In this section, we will:
- Draw the minute hand
3
pixels wide, dark gray (#423
), and 65% of the clock’s radius. - Draw the minute hand
4
pixels wide, dark blue (#42F
), and 45% of the clock’s radius.
What you need to know
You know everything at this point. You got this!
The solution
Update the JavaScript tab to:
import { Component } from "//unpkg.com/can@5/core.mjs";
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
Component.extend({
tag: "analog-clock",
view: `<canvas this:to="canvasElement" id="analog" width="255" height="255"></canvas>`,
ViewModel: {
// the canvas element
canvasElement: HTMLCanvasElement,
// the canvas 2d context
get canvas() {
return this.canvasElement.getContext("2d");
},
drawNeedle(length, base60Distance, styles, center) {
Object.assign(this.canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
this.canvas.beginPath();
this.canvas.moveTo(center, center);
this.canvas.lineTo(x, y);
this.canvas.closePath();
this.canvas.stroke();
},
connectedCallback(element) {
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
this.listenTo("time", (ev, time) => {
this.canvas.clearRect(0, 0, diameter, diameter);
// draw circle
this.canvas.lineWidth = 4.0;
this.canvas.strokeStyle = "#567";
this.canvas.beginPath();
this.canvas.arc(center, center, radius, 0, Math.PI * 2, true);
this.canvas.closePath();
this.canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
this.drawNeedle(
radius * 0.85,
seconds, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
},
center
);
// draw minute hand
const minutes = time.getMinutes() + seconds / 60;
this.drawNeedle(
radius * 0.65,
minutes, {
lineWidth: 3.0,
strokeStyle: "#423",
lineCap: "round"
},
center
);
// draw hour hand
const hoursInBase60 = time.getHours() * 60 / 12 + minutes / 60;
this.drawNeedle(
radius * 0.45,
hoursInBase60, {
lineWidth: 4.0,
strokeStyle: "#42F",
lineCap: "round"
},
center
);
});
}
}
});
Component.extend({
tag: "digital-clock",
view: "{{ hh() }}:{{ mm() }}:{{ ss() }}",
ViewModel: {
time: Date,
hh() {
const hr = this.time.getHours() % 12;
return hr === 0 ? 12 : hr;
},
mm() {
return this.time.getMinutes().toString().padStart(2, "00");
},
ss() {
return this.time.getSeconds().toString().padStart(2, "00");
}
}
});
Component.extend({
tag: "clock-controls",
ViewModel: {
time: {
value({ resolve }) {
const intervalID = setInterval(() => {
resolve( new Date() );
}, 1000);
resolve( new Date() );
return () => clearInterval(intervalID);
}
}
},
view: `
<p>{{ time }}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`
});
Result
When finished, you should see something like the following CodePen:
See the Pen Canvas Clock (Simple) [Finished] by Bitovi (@bitovi) on CodePen.