Canvas Clock (Simple)
This 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:
CanJS Canvas Clock on jsbin.com
The following sections are broken down 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 CLONING THE FOLLOWING JS BIN:
Click the JS Bin button. The JS Bin will open in a new window. In that new window, under
File
, clickClone
.
CanJS Canvas Clock on jsbin.com
This JS Bin 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 JS Bin 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:
can.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"/>
`
});
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:
can.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");
}
}
});
can.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 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; it will be passed the
element
like:can.Component.extend({ tag: "my-element", view: "<h1>first child</h1>", ViewModel: { connectedCallback(element) { element.firstChild //-> <h1> } } });
- 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:canvas.lineWidth = 4.0 canvas.strokeStyle = "#567"
Then make arcs and lines for your path like:canvas.beginPath()
Then close the path like:canvas.arc(125, 125, 125, 0, Math.PI * 2, true)
Finally, use stroke to actually draw the line:canvas.closePath()
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:
can.Component.extend({
tag: "analog-clock",
view: '<canvas id="analog" width="255" height="255"></canvas>',
ViewModel: {
connectedCallback(element) {
const canvas = element.firstChild.getContext("2d");
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
// draw circle
canvas.lineWidth = 4.0;
canvas.strokeStyle = "#567";
canvas.beginPath();
canvas.arc(center, center, radius, 0, Math.PI * 2, true);
canvas.closePath();
canvas.stroke();
}
}
});
can.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");
}
}
});
can.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:can.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:
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
can.Component.extend({
tag: "analog-clock",
view: '<canvas id="analog" width="255" height="255"></canvas>',
ViewModel: {
connectedCallback(element) {
const canvas = element.firstChild.getContext("2d");
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
// draw circle
canvas.lineWidth = 4.0;
canvas.strokeStyle = "#567";
canvas.beginPath();
canvas.arc(center, center, radius, 0, Math.PI * 2, true);
canvas.closePath();
canvas.stroke();
this.listenTo("time", (ev, time) => {
canvas.clearRect(0, 0, diameter, diameter);
// draw circle
canvas.lineWidth = 4.0;
canvas.strokeStyle = "#567";
canvas.beginPath();
canvas.arc(center, center, radius, 0, Math.PI * 2, true);
canvas.closePath();
canvas.stroke();
Object.assign(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));
canvas.beginPath();
canvas.moveTo(center, center);
canvas.lineTo(x, y);
canvas.closePath();
canvas.stroke();
});
}
}
});
can.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");
}
}
});
can.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
function
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: { connectedCallback() { const canvas = element.firstChild.getContext("2d"); const diameter = 255; const radius = diameter/2 - 5; const center = diameter/2; const drawNeedle = (length, base60Distance, styles) => { canvas // -> the canvas element // ... }; } }
The solution
Update the JavaScript tab to:
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
can.Component.extend({
tag: "analog-clock",
view: '<canvas id="analog" width="255" height="255"></canvas>',
ViewModel: {
connectedCallback(element) {
const canvas = element.firstChild.getContext("2d");
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
const drawNeedle = (length, base60Distance, styles) => {
Object.assign(canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
canvas.beginPath();
canvas.moveTo(center, center);
canvas.lineTo(x, y);
canvas.closePath();
canvas.stroke();
};
this.listenTo("time", (ev, time) => {
canvas.clearRect(0, 0, diameter, diameter);
// draw circle
canvas.lineWidth = 4.0;
canvas.strokeStyle = "#567";
canvas.beginPath();
canvas.arc(center, center, radius, 0, Math.PI * 2, true);
canvas.closePath();
canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
drawNeedle(
radius * 0.85,
seconds, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
}
);
});
}
}
});
can.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");
}
}
});
can.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:
// 60 = 2π
const base60ToRadians = (base60Number) =>
2 * Math.PI * base60Number / 60;
can.Component.extend({
tag: "analog-clock",
view: '<canvas id="analog" width="255" height="255"></canvas>',
ViewModel: {
connectedCallback(element) {
const canvas = element.firstChild.getContext("2d");
const diameter = 255;
const radius = diameter/2 - 5;
const center = diameter/2;
const drawNeedle = (length, base60Distance, styles) => {
Object.assign(canvas, styles);
const x = center + length * Math.sin(base60ToRadians(base60Distance));
const y = center + length * -1 * Math.cos(base60ToRadians(base60Distance));
canvas.beginPath();
canvas.moveTo(center, center);
canvas.lineTo(x, y);
canvas.closePath();
canvas.stroke();
};
// draw second hand
this.listenTo("time", (ev, time) => {
canvas.clearRect(0, 0, diameter, diameter);
// draw circle
canvas.lineWidth = 4.0;
canvas.strokeStyle = "#567";
canvas.beginPath();
canvas.arc(center, center, radius, 0, Math.PI * 2, true);
canvas.closePath();
canvas.stroke();
// draw second hand
const seconds = time.getSeconds() + this.time.getMilliseconds() / 1000;
drawNeedle(
radius * 0.85,
seconds, {
lineWidth: 2.0,
strokeStyle: "#FF0000",
lineCap: "round"
}
);
// draw minute hand
const minutes = time.getMinutes() + seconds / 60;
drawNeedle(
radius * 0.65,
minutes,
{
lineWidth: 3.0,
strokeStyle: "#423",
lineCap: "round"
}
);
// draw hour hand
const hoursInBase60 = time.getHours() * 60 / 12 + minutes / 60;
drawNeedle(
radius * 0.45,
hoursInBase60,
{
lineWidth: 4.0,
strokeStyle: "#42F",
lineCap: "round"
}
);
});
}
}
});
can.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");
}
}
});
can.Component.extend({
tag: "clock-controls",
ViewModel: {
time: {Default: Date, Type: Date},
init() {
setInterval(() => {
this.time = new Date();
}, 1000);
}
},
view: `
<p>{{time}}</p>
<digital-clock time:from="time"/>
<analog-clock time:from="time"/>
`
});
Result
When finished, you should see something like the following JS Bin: