- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Guided: Dynamic Graphics with Canvas and SVG
This lab will guide you through the parallel construction of a JavaScript-based interactive graphics demo with two different web technologies — the Canvas API and SVGs — to give you first-hand experience with their pros, cons, and other differences. The lab content assumes you're familiar with JavaScript, HTML, CSS, and basic graphics concepts like pixels and RGB values.
Lab Info
Table of Contents
-
Challenge
Introduction
This lab will guide you through the parallel construction of a JavaScript-based interactive graphics demo with two different web technologies — the Canvas API and SVGs — to give you first-hand experience with their pros, cons, and other differences. The lab content assumes you're familiar with JavaScript, HTML, CSS, and basic graphics concepts like pixels.
Some notes:
- You'll do all your coding in
/index.html, building on your solution with every task. - Per best practices, it's recommended you keep strict mode enabled by leaving
'use strict'as the first line of the<script>area. - If you get stuck on a task, you can consult the
solutions/folder.
- You'll do all your coding in
-
Challenge
Basic Setup
As its name suggests, the Canvas API provides canvases that you can "paint" upon — that is, you can set the colors of their individual pixels.
To create a canvas, use the
<canvas>HTML element, specifying its width and height in pixels:<canvas height="160" width="160"></canvas>During development, it can help to make the boundaries of the canvas visible with some CSS:
canvas { border: 1px solid black; }With canvases, everything else generally happens using JavaScript. First you need to get the HTML element, then to be able to draw on the canvas, you need its drawing context (commonly abbreviated
ctx):var canvas = document.querySelector('canvas') var ctx = canvas.getContext('2d')You can now use the
CanvasRenderingContext2Dobject stored inctxfor various drawing operations, such as setting the color for all subsequent fill operations (those which draw filled-in shapes):ctx.fillStyle = 'gold'If you want to draw a circle,
ctxhas two methods:arcandellipsemethod. Normally,arcis a slightly simpler choice, but usingellipsewill put you in an easier position to animate your circle a certain way in later tasks:var centerX = canvas.width / 2 var centerY = canvas.height / 2 var radius = canvas.width / 3 ctx.ellipse(centerX, centerY, radius, radius, 0, 0, 2 * Math.PI)The code above creates a canvas-centered circle by passing the same value for the x-axis radius as the y-axis radius. The last three parameters, which are required, are:
rotation: Since rotating a circle doesn't do anything, use0(radians) here.startAngle: Theellipsefunction is capable of making arcs, not just whole ellipses. But since you want a whole circle/ellipse, set this to0(radians) to start the arc at the positive x-axis, i.e., pointing to the right.endAngle: Since there are 2π radians in a full circle, set this to2 * Math.PI.
Unlike other methods, like
fillRect, callingellipseon its own adds to the context's current path but doesn't actually draw anything. To close off the current path and draw a filled rendition of it, simply callctx.fill(). As with the Canvas API, to work with SVGs, you create an HTML element (which can also benefit from a CSS border) to house them:<svg width="160" height="160"></svg>Where a canvas needs JavaScript to render a circle or ellipse, SVGs let you add them declaratively, within the
<svg>HTML element:<ellipse cx="50%" cy="50%" rx="33.3%" ry="33.3%" fill="gold" />While you can't specify calculations for the center and radius values like JavaScript allows, you can specify percentages relative to the parent SVG element, as seen above.
With that, you should have the Canvas and SVG areas behaving identically. Just about anything you can do declaratively with HTML, you can do with JavaScript. That's true of SVG, too: You could also have created the
ellipseelement dynamically, but that will come in a later task. -
Challenge
Animation
It's time to bring your circle to life with a bit of animation. A naive approach might involve putting your drawing into a loop. The problem is, not all computers will execute the loop at the same speed. Even the same computer under differing levels of background load may vary its performance considerably, despite a multi-core CPU.
A slightly less naive approach might use
setTimeoutorsetInterval, but under load, even these can cause janky visuals.The solution is to use the Web API's
requestAnimationFramefunction. Pass it a callback function to be called before the next repaint (typically happening 60 times per second). The advantage is that the callback function will be called with a timestamp, allowing you to calculate how long has passed since the last time you drew a frame. Like this, the movement of your animation won't slow down under load, even if its FPS (number of frames per second being rendered) drops.To make a loop, have your callback call
requestAnimationFrame, then also call it once outside that to trigger the loop:requestAnimationFrame(step) // trigger the loop beginning function step(timestamp) { // do some drawing here based on the value of timestamp requestAnimationFrame(step) // continue the next step of the loop when ready }A common technique is to store the value of
timestampand take the difference between the current and previous values, then base the next frame of the animation on this elapsed (or "delta") value. For example, you could move something one pixel for every 10 milliseconds that pass.Even simpler, you can use a sine (or any other periodic) function on the
timestampvalue directly, since timestamp can keep increasing forever, butMath.sin(timestamp)will always return a value between -1 and 1. You can divide or multiplytimestampto control the speed, then divide or multiply the return value ofMath.sinto change the range:var animationFactor = 10 * Math.sin(timestamp / 100)Here,
animationFactorwill loop between -10 and 10 and back every 200π (~628) milliseconds or so. You can use that to animate the circle by changing thecenterYandradiusvariables to become base values:var centerYBase = canvas.height / 2 var radiusBase = canvas.width / 3Inside your callback, these base values are then used in each frame to calculate current values using
animationFactor(thoughcenterXhappens to remain static):var centerY = centerYBase + animationFactor var radiusX = radiusBase + animationFactor var radiusY = radiusBase - animationFactorThe first two lines will have the ellipse moving downward, becoming wider, and becoming shorter, then the opposite (upward, narrower, taller), in a smooth, sinusoidal loop, so long as you use these calculated values now — including the now-distinct radius values — in your
ellipsecall:ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI)But the callback will just repeatedly draw overtop whatever's already on the canvas. To make it actually animate, clear the canvas and reset the current path before drawing:
ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.beginPath()With those lines in their proper place, your left-hand disc should be dancing.
(Note that it's not required in this lab, but calling
ctx.save()before drawing andctx.restore()afterward, to sandbox temporary drawing state changes, is considered a best practice in more complicated contexts.) As for SVG, it can be animated via CSS or JavaScript. Since you're already halfway there with your canvas implementation, you can stick with JavaScript to see the similarities. Feel free to comment your JavaScript as you go to show which parts apply to just canvas, just SVG, or both — and note that these will shift from task to task.To work with your SVG's nested
<ellipse>element, get a reference to it:var svgEllipse = document.querySelector('ellipse')You can then set its attributes within your callback to match what's changing in each frame:
svgEllipse.setAttribute('cy', centerY) svgEllipse.setAttribute('rx', radiusX) svgEllipse.setAttribute('ry', radiusY)With that, you should once again have the Canvas and SVG areas behaving identically. A side effect of using the same calculation to animate both the canvas and the SVG is that they necessarily stay synchronized. In the next task, however, you'll intentionally avoid this.
-
Challenge
Events on a Simple Target
Suppose you want your ellipse, instead of being set with
ctx.fillStyle = 'gold', to cycle through different fill colors when clicked:var colors = ['gold', 'chartreuse', 'teal', 'hotpink'] var ctxColor = 0 function setNextCtxFillStyle() { ctx.fillStyle = colors[ctxColor] if (++ctxColor >= colors.length) ctxColor = 0 }How can you wire this up to your canvas ellipse? Making it respond to pointer events may be slightly more complicated than you would expect. The main reason for this is that you can't use
addEventListenerdirectly on something you draw on a canvas, since your drawings aren't DOM objects. The closest you can get is a click handler on the canvas itself:canvas.addEventListener('click', canvasClickHandler)That's a start, but how can you tell whether the user clicked the circle or the empty area of the canvas? There are a couple options: Calculating a "hitbox" or checking the color of the pixel that was clicked.
A hitbox (interaction-enabled area) doesn't necessarily have to match the exact shape of its corresponding visual — a common technique in, for example, video games, is to approximate a more complicated visual figure with a simple rectangle. Since the shape in this case is an ellipse, you could, for example, use a known math formula to determine whether a given point falls within the ellipse or not, without approximating.
However, in the uncluttered environment of this example, a color check is simplest and will give you a perfectly precise result. The first step is to get the coordinates within the canvas by subtracting the canvas offset from the click event coordinates:
function canvasClickHandler({ clientX, clientY }) { var x = clientX - canvas.offsetLeft var y = clientY - canvas.offsetTop }You can then call
ctx.getImageDataon a one-pixel-square area at these coordinates, and take thedataproperty (anImageDataobject) of the result:var pixel = ctx.getImageData(x, y, 1, 1).data(It's possible for
getImageDatato throw an exception, so in a production context, a try/catch block might make sense, depending on your logging setup and expectations about the execution environment.)pixelwill now consist of an array of RGBA values, each from 0 to 255. Your filled ellipse should be the only thing with an A value of 255:if (pixel[3] === 255) setNextCtxFillStyle()With that, clicking the ellipse on the left should cycle through your four-color palette, but clicking elsewhere within the canvas should have no effect. Responding to clicks on specific shapes is much more straightforward with SVG, since the
<ellipse>element is a DOM object and can have a direct event listener:svgEllipse.addEventListener('click', setNextEllipseFillAttribute)To keep the colors of the canvas and SVG ellipses independent, declare a separate variable,
svgColor = 0, for your click handler to use. Unlike your canvas click handler, your SVG ellipse click handler doesn't need to know about coordinates — it can just use thetargetproperty of the event to set the fill color:function setNextEllipseFillAttribute({ target }) { target.setAttribute('fill', colors[svgColor]) if (++svgColor >= colors.length) svgColor = 0 }Lastly, matching the removal of the static
fillStylefrom the canvas will mean removing thefill="gold"attribute from the SVG ellipse HTML, letting it return, initially, to its default color.With that, the two halves of your demo should once again match in terms of their behavior — albeit while tracking their click/color state separately rather than completely mirroring each other. Once again, the SVG solution was a bit shorter, thanks to being able to use a direct click handler. That said, keep in mind that certain aspects are being reused from the previous canvas-focused task, like the
colorsarray. -
Challenge
Events on Independent Targets
Detecting whether a click was in one lonely ellipse was doable. What happens when there are, say, 20 randomly sized and randomly placed ellipses? To cycle an ellipse's fill color, you'll additionally have to know which ellipse was clicked.
Start with tracking the
centerX,centerYBase,radiusBase, andctxColorbelonging to each ellipse using anellipsesarray, outside thestepcallback. You won't needvar ctxColoranymore, but the other three are still needed by the SVG part of the code for now.var ellipses = [] for (let i = 0; i < 20; i++) { ellipses.push({ centerX: Math.random() * canvas.width, centerYBase: Math.random() * canvas.height, radiusBase: Math.max((Math.random() * canvas.width) / 3, 10), ctxColor: Math.floor(Math.random() * colors.length), }) }If you're not familiar with these
Mathmembers:randomreturns a number from 0 up to (but excluding) 1.maxreturns the larger of two numbers passed to it.- Above, this means each
radiusBasewill be at least 10 and at most one third of the canvas width.
- Above, this means each
floorreturns the largest integer less than or equal to its input.- Above, this means each
ctxColormay be any validcolorarray index.
- Above, this means each
Since
ctxColoris now ellipse-specific and no longer avar,setNextCtxFillStylewon't work anymore. It will need to becomesetCtxFillStyleAndGetNextand:- To take
ctxColoras a parameter. - To
return ctxColorso the caller can store the result as the new ellipse-specificctxColor(in the case of color rotation) or ignore it (in the case of a regular animation frame).
Within the
stepcallback, your single-ellipse-drawing code will need to become part of a loop overellipses:for (let i = 0; i < ellipses.length; i++) { let { centerX, centerYBase, radiusBase, ctxColor } = ellipses[i] let centerY = centerYBase + animationFactor let radiusX = Math.max(radiusBase + animationFactor, 1) let radiusY = Math.max(radiusBase - animationFactor, 1) ctx.beginPath() ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI) setCtxFillStyleAndGetNext(ctxColor) ctx.fill() ctx.stroke() }Here,
Math.maxensures the radiuses don't become too small in edge cases;letis used overvarto ensure the block-scoped variables are used just for the current canvas ellipse and don't interfere with theirvarcounterparts in the surrounding function scope, which are still used for the SVG ellipse. Thectx.strokecall adds an outline to each ellipse to make overlapping ones of the same color easier to distinguish from one another.When it comes to determining which ellipse was clicked, it's time to upgrade your approach. Instead of checking the color, or checking a math formula for an ellipse, you can leverage a built-in function,
ctx.isPointInPath, for this check. It takes a path and x- and y-coordinates. But where do you get a path, given thatctx.ellipsedoesn't return one?The correct object is a
Path2D. Instead of callingctx.beginPathandctx.ellipse, you can instantiate aPath2Dobject and then call itsellipsemethod to store the ellipse shape instead of drawing it:let path = new Path2D() path.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI) ellipses[i].path = pathThen, when it comes to
fillandstroke, the drawing context will no longer have a path ready, so they'll need you to pass them your storedpath:ctx.fill(path) ctx.stroke(path)With a
.pathmember on all yourellipsearray values, your click handler can now iterate and callisPointInPath:for (let i = 0; i < 20; i++) { if (ctx.isPointInPath(ellipses[i].path, x, y)) { ellipses[i].ctxColor = setCtxFillStyleAndGetNext(ellipses[i].ctxColor) } }Like this, you no longer need to check pixel color values from
getImageData. (Bonus: This approach would work with more complex shapes, too.) Note that any ellipses under the cursor receive the click, not just the topmost one. In certain contexts (such as user interfaces and objects in 2D games) it can be appropriate to avoid this issue by ensuring targets don't overlap in the first place. In this particular case, since you have absolute control over the drawing order and everything being drawn, it would have been sufficient to reverse-loop over theellipsesarray and exit the loop as soon as there's a hit. Other algorithms for different behavior than this are beyond the scope of this lab.This facet of the previous task further highlights the differences between the design problems involved for canvas versus SVG solutions. As you may have guessed, the ability of
<ellipse>to have a direct click handler will mitigate the above issue entirely as you implement multiple overlapping click targets using SVG.The first step is removing the single, static
<ellipse>element from your HTML (leaving the parent<svg ...>element) along with the single-ellipse setup code outside yourstepcallback.To add children to the
<svg>element dynamically, a reference to it in the outer script scope will be handy:var svg = document.querySelector('svg')Next, much of the
ellipsesarray can be reused. Following the pattern of previous tasks, you'll have the initial positions and colors match, but afterward track the SVG ellipse colors independent of their canvas counterparts. Unlike the canvas solution, where ellipse-drawing occurs each frame, the SVG ellipse creation will occur within the initialellipsessetup loop:let svgEllipse = document.createElementNS( 'http://www.w3.org/2000/svg', 'ellipse' ) svg.appendChild(svgEllipse)You have to use
createElementNSwith the first argument as shown, otherwise the element won't function as SVG — it'll function in the parent HTML namespace, and therefore be interpreted as broken or invalid HTML. Technically, you should usesetAttributeNS(null, ..., ...)after that, but for most practical applications, the simplersetAttributewill work:svgEllipse.setAttribute('stroke', 'black') svgEllipse.svgColor = ellipses[i].ctxColor svgEllipse.addEventListener('click', setNextEllipseFillAttribute) ellipses[i].svgEllipse = svgEllipseHere,
setAttributesets the stroke, since in SVG it defaults to none, unlike the black default in canvas. You'll need a reference tosvgEllipseto change its attributes in yourstepcallback, hence its storage as a property ofellipses[i]. As forsvgColor, instead of being a direct property ofellipses[i], it's attached tosvgEllipsefor access in the click handler.In the
stepcallback, the single-ellipse code can join the loop, minus the declarations ofcenterY,radiusX, andradiusY, since those values will now come fromellipses[i]at the top of the loop. SincesvgEllipseno longer exists in the outer scope, it must come fromellipses[i]like the others.With that, the
svgEllipse.setAttributecalls in the loop will work, but they're incomplete:- There's no HTML declaration setting
cxanymore, so it must be set tocenterX. - Likewise, to match the "color from the beginning" canvas behavior of the previous task,
fillmust be set tocolors[svgEllipse.svgColor].
Finally,
setNextEllipseFillAttributerefers to the now-removed outer-scopesvgColor. It will need to get it fromtargetinstead:var svgColor = target.svgColorAnd it will need to save the newly-rotated color value so
stepcan pick it up on the next frame:target.svgColor = svgColorWith that, the canvas and SVG behaviors will again match — except for the SVG click handler only changing the topmost ellipse's color as discussed above. It was easier with SVG to make it so that only the topmost ellipse reacted to a click. What if you preferred the canvas behavior? In this case, you could use
document.elementsFromPoint(clientX, clientY)in the event handler, then filter ontagName === 'ellipse', and run the handler on all results instead of just the current target:function setNextEllipseFillAttribute({ clientX, clientY }) { for (let target of document.elementsFromPoint(clientX, clientY)) { let svgColor = target.svgColor if (++svgColor >= colors.length) svgColor = 0 target.svgColor = svgColor } }This handler could then be attached to just the
<svg>element as a whole, instead of individual<ellipse>elements, similar to the canvas approach.--
Congratulations on completing this lab!
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.