Rewriting an old project! Part 2: JavaScript
Ken Bellows
Posted on April 12, 2019
Note
This is Part 2 of a miniseries in which I'm revisiting an old weekend hack of mine from college, which I called jSphere (because I was lazy and couldn't come up with anything more interesting).If you haven't read Part 1, which was all about the HTML and CSS, I recommend giving it a skim, or at least reading the Backstory section at the beginning.
As I said last time, I'm not going to discuss what the code does in this post, just its structure and syntax. But I do plan to write a proper walkthrough and tutorial later.
Last time...
In Part 1, time we went over the markup and styling of , and I already found that pretty interesting. But, I mean, this project is primarily a JavaScript project. It's all about that <canvas>
!
For reference, here's what we're rebuilding:
Okay. Where to begin? Man, there was so much to change about this code, and a lot of it was interwoven, which makes it tough to really talk about step-by-step. So what I'll have to do is just... go for it, I guess.
Please, feel free to critique or otherwise comment on the code here! I love feedback and (constructive) criticism, I'm always interested in how other people would approach the same tasks, and I've even been known to enjoy a little bikeshedding now and then.
Minutia: nah.
Let me mention up front here that there are some changes I won't explicitly discuss, because they're too minor. Yes, I changed all the var
s to const
s and let
s, I reordered some functions, I changed all the TitleCasedMethods to normalCamelCase (why did I capitalize the first letter of all my methods? was I insane?). But you don't care about all those things. I'm going to focus on the really large things, the things I found interesting or funny, and the things that really demonstrate how far the language has come in seven years.
Let's start with a funny one.
Bro, do you even loop?
I fixed a spot where I had done something super weird, maybe because I was rushing and didn't think it through. This is in Sphere
's constructor, when I'm generating all the Dot
instances needed to represent the points on the surface of the sphere. I use a nested for
loop to move around the sphere in rings, top to bottom, adding dots to its surface as I went. This is fine, but I wrote the loops in a super weird way:
this.points = new Array();
var angstep = Math.PI/10;
var i = 0;
// Loop from 0 to 2*pi, creating one row of points at each step
for (var angxy=0; angxy<2*Math.PI; angxy+=angstep){
this.points[i] = new Array();
var j=0;
for (var angyz=0; angyz<2*Math.PI; angyz+=angstep) {
// Loop from 0 to 2*pi, creating one point at each step
var px = r * Math.cos(angxy) * Math.sin(angyz) + x,
py = r * Math.sin(angxy) * Math.sin(angyz) + y,
pz = r * Math.cos(angyz) + z,
pfg = pz > z;
this.points[i][j] = new Dot(px,py,pz,pfg);
j++;
}
i++;
}
What's up with the i
and j
variables there, hanging out outside of the loops? Why aren't they created and incremented within the loops? I definitely knew that a for
loop can have multiple variables; why didn't I just do this?
for (var i=0, angxy=0; angxy<2*Math.PI; i++, angxy+=angstep) { ... }
But also, why do I even need those index counters in the first place? Why do I use this.points[i] =
and this.points[i][j] =
to add to the arrays? Why aren't I just using [].push()
to add stuff to the array? And why on Earth did I use new Array()
instead of []
? How much OOP was I on???
So I got rid of all that nonsense. I also decided to make this.points
a single flat list of points, rather than a 2D matrix with rows, because I realized while refactoring some other code that the only way I ever referenced this.points
was with a couple of nested loops to reach each point, usually with weird for ... in
loops like this:
for (var i in this.points) {
for (var j in this.points[i]) {
this.points[i][j]...
}
}
First, it does work, but using for...in
loops on arrays like this is strange, and it's unexpected to say the least. I was very confused when I first saw it, and I imagine any other devs reading it would be thrown off as well.
Second, I never used i
or j
for anything other than indexing into this.points
, which makes this a great candidate for the much more elegant for...of
loop, which didn't exist when I originally wrote this code.
I refactored to fix all of these problems, and here's where I wound up. The constructor loops were updated to this (which also includes a change to the Dot constructor that I'll talk about in a bit):
// The angle delta to use when calculating the surface point positions;
// a larger angstep means fewer points on the surface
const angstep = Math.PI/10;
this.points = [];
// Loop from 0 to 2*pi, creating one row of points at each step
for (let angxy=0; angxy<2*Math.PI; angxy+=angstep){
for (let angyz=0; angyz<2*Math.PI; angyz+=angstep) {
// Loop from 0 to 2*pi, creating one point at each step
this.points.push(new Dot({
x: r * Math.cos(angxy) * Math.sin(angyz) + x,
y: r * Math.sin(angxy) * Math.sin(angyz) + y,
z: r * Math.cos(angyz) + z,
fg: Math.cos(angyz) > 0
}));
}
}
And the loops over the elements of this.points
now look like this:
for (const point of this.points) {
point...
}
Ahh... So much better. 😌
Classes!
Since this project was all about this interactive sphere composed of a bunch of dots on its surface, and since I was doing a CS degree in 2012 and the Functional Revolution of the last few years hadn't begun yet, the project is largely composed of two high-level object types:
-
Dot
- represents a single dot on the surface of the sphere -
Sphere
- represents the whole sphere, with all of itsDot
s
In 2012, the native class
was yet to be standardized, so I wrote everything in the old-school function
-as-a-constructor style. For exapmle, here's part of the Sphere
constructor:
function Dot(x,y,z,fg,fgColor,bgColor) {
// Default Values: { x: 0, y: 0, z: 0, fg: true, fgcolor: #7EE37E, bgcolor: "#787878" }
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
this.z = typeof z !== 'undefined' ? z : 0;
this.fg = typeof fg !== 'undefined' ? fg : true;
this.fgColor = typeof fgColor !== 'undefined' ? fgColor : "#7EE37E";
this.bgColor = typeof bgColor !== 'undefined' ? bgColor : "#787878";
this.Draw = function(ctx) {
// Store the context's fillStyle
var tmpStyle = ctx.fillStyle;
// Set the fillStyle for the dot
ctx.fillStyle = (this.fg ? this.fgColor : this.bgColor);
// Draw the dot
ctx.fillCircle(x,y,this.fg?10:5);
// Restore the previous fillStyle
ctx.fillStyle = tmpStyle;
}
}
Now, there's another issue here, which probably had to do with my limited understanding of JS prototypes: every instance of Dot
defines its own copy of the Draw
function, rather than referring to a class method. What I should have written is this:
function Dot(x,y,z,fg,fgColor,bgColor) {
// ...
}
Dot.protoype.Draw = function(ctx) {
// Store the context's fillStyle
var tmpStyle = ctx.fillStyle;
// Set the fillStyle for the dot
ctx.fillStyle = (this.fg ? this.fgColor : this.bgColor);
// Draw the dot
ctx.fillCircle(x,y,this.fg?10:5);
// Restore the previous fillStyle
ctx.fillStyle = tmpStyle;
}
This declares a single copy of the function, rather than wasting memory on a new function every time.
But that's a side issue anyway, since the native class
es introduced in ES2015 (aka ES6) handles that under the hood!
But before we get to that, let mention one more thing: ES2015 also introduced object destructuring, function parameter default values, and using both of those things together to basically get the equivalent of named parameters with default values, as seen in languages like Python and Ruby!
Aaaaand one more thing: ES2015 also introduced Object.assign()
, which I like to use to initialize this
with properties much more succinctly than the traditional one-by-one method. (Man, ES2015 was some good stuff!)
Using these two techniques together, I can eliminate this nasty snippet from the the Dot
constructor:
// Gross 🤢
this.x = typeof x !== 'undefined' ? x : 0;
this.y = typeof y !== 'undefined' ? y : 0;
this.z = typeof z !== 'undefined' ? z : 0;
this.fg = typeof fg !== 'undefined' ? fg : true;
this.fgColor = typeof fgColor !== 'undefined' ? fgColor : "#7EE37E";
this.bgColor = typeof bgColor !== 'undefined' ? bgColor : "#787878";
Putting all these improvements together, I can rewrite the Dot
like this:
class Dot {
constructor({x=0, y=0, z=0, fg=true, fgColor='#7EE37E', bgColor='#787878'}={}) {
Object.assign(this, {x, y, z, fg, fgColor, bgColor});
}
draw(ctx) {
// Store the context's fillStyle
var tmpStyle = ctx.fillStyle;
// Set the fillStyle for the dot
ctx.fillStyle = (this.fg ? this.fgColor : this.bgColor);
// Draw the dot
ctx.fillCircle(x, y, this.fg ? 10 : 5);
// Restore the previous fillStyle
ctx.fillStyle = tmpStyle;
}
}
So much cleaner!
Re-separating my concerns
As I was rewriting my classes as I just described, I started to find places where Dot
s were doing stuff that was better handled by Sphere
, and Sphere
was repeatedly performing operations on each Dot
that could be much more cleanly handled internally by each of them.
First, that Draw
method of the Dot
class up there? Yeah, it was never actually being called. It turned out that I needed some context from the Sphere
about each Dot
in order to draw it correctly, namely where it was located on the surface of the sphere and how the sphere was currently rotated. And rather than pass all that context around to each Dot
, it was much cleaner to do all my drawing right from the Sphere
class. So... I deleted the Draw
method on Dot
, as well as the fgColor
and bgColor
properties that went with it.
Second, there are two major ways that the Dot
s are changed to move the Sphere
around: they're translated (moved around), and they're scaled (grown or shrunken). I had a bunch of repeated code in my event handlers where each Dot
's properties were being manually recalculated and updated in the same way, so I figured I'd DRY up my code and move the logic to change a Dot
into the Dot
class itself. I gave Dot
two new methods: scale
and translate
.
After all that, my Dot
class looks like this:
class Dot {
constructor({x=0, y=0, z=0, fg=true}={}) {
Object.assign(this, {x, y, z, fg});
}
/**
* Scale this dot's position by multiplying all coordinates by the given scale factor
* @param {Number} scaleFactor
*/
scale(scaleFactor) {
this.x *= scaleFactor
this.y *= scaleFactor
this.z *= scaleFactor
}
/**
* Move this dot to a new position indicated by the given x, y, and z distances
* @param {Number} [x=0]
* @param {Number} [y=0]
* @param {Number} [z=0]
*/
translate({x=0, y=0, z=0}) {
this.x += x
this.y += y
this.z += z
}
}
Don't mess with the prototype!
I made several changes that came from experience. Before I left college, I really had little to no knowledge of accepted community best practices, or why they were important. For example, I have a few utility functions for drawing on the canvas. In my old code, I added them to the CanvasRenderingContext2D
prototype directly, as follows:
Object.getPrototypeOf(ctx).fillCircle = ctxFillCircle;
This is not ideal. Suppose a real ctx.fillCircle
were added to the spec one day, and it was different from my code. In this minor demo it probably doesn't matter much, as I'm the only one working with the code, but in a project with multiple authors, it could be very confusing to come across a standardized method that looks different than it should. Even future me might be confused!
So I refactored: a ctx
parameter was added to each function, all references to this
in the functions were changed to ctx
, I removed the above lines that modified the CanvasRenderingContext2D
prototype, and I changed the code to call the functions directly and pass ctx
as the first argument rather than calling them as a method of ctx
.
Removing jQuery
Finally, jQuery. I love it, it did a lot of good for the web, but in most cases, including mine, it's no longer needed.
I basically used jQuery for three things in this project, two of which are trivially replaced:
- I used the
$(function() { ... })
wrapper to delay code execution until the page loaded. As I discussed in Part 1, this is unnecessary if you add thedefer
attribute to any<script>
tags that need to wait. (Thanks again, @crazytim!) - I used the classic
$()
selector function in several places to get elements. This is now (and actually was then, though I was unfamiliar with it) a native part of the platform withdocument.querySelector
anddocument.querySelectorAll
. - The tough one: I used the
jquery.events.drag
plugin to handle mouse-based interactivity. This one takes slightly more doing to replace.
The first two are super basic, and I won't bother showing them. Instead, I'll focus on that third one: the jquery.event.drag
event plugin.
Here's how I was using it:
$("#canvas")
.drag("init", function(e){
startX = e.clientX;
startY = e.clientY;
})
.drag(function(e){
if (e.ctrlKey) {
if (e.altKey) sphere.HiddenFun2(e.clientX-startX,e.clientY-startY);
else sphere.Zoom(e.clientX-startX, e.clientY-startY, -1);
} else if (e.shiftKey) {
if (e.altKey) sphere.HiddenFun1(e.clientX-startX,e.clientY-startY);
else sphere.Pan(e.clientX-startX,e.clientY-startY);
} else {
sphere.Rotate(e.clientX-startX,e.clientY-startY);
}
startX = e.clientX;
startY = e.clientY;
});
So when dragging begins, store the starting position of the cursor, then when dragging continues, perform certain actions based on the modifier keys used, and update the cursor position for the next movement.
So how would we do this with regular old mouse
events?
I implemented it in three stages: drag start, drag, and drag end. The drag start event is mousedown
; we'll record the starting position of the cursor, and add the drag and drag end event listeners. The drag event will be mousemove
; here we'll do all the logic to check for modifier keys and actually manipulate the sphere. Finally, I'll use two events for drag stop: mouseup
and mouseout
. That way, we don't get that weird case where you move your mouse out of the window and then let go, and the app is stuck in the dragging state until you click again. Those events just remove the event handlers registered on drag start.
I decided to add mousemove
, mouseup
, and mouseout
to the window
so that you can move your mouse around the page after clicking. And I had to suppress the mouseout
event on the canvas
and body
elements using event.stopPropagation()
to prevent prematurely triggering the drag stop when moving your mouse in and out of the canvas.
Here's what all that looks like:
let dragOrigin;
canvas.addEventListener('mousedown', dragStartHandler)
function dragStartHandler(e){
dragOrigin = {
x: e.clientX,
y: e.clientY
};
document.addEventListener('mousemove', dragHandler);
window.addEventListener('mouseup', dragStopHandler);
window.addEventListener('mouseout', dragStopHandler);
canvas.addEventListener('mouseout', stopPropagation);
document.body.addEventListener('mouseout', stopPropagation);
}
function dragHandler(e){
if (e.ctrlKey || e.metaKey) {
if (e.altKey) sphere.hiddenFun2(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
else sphere.zoom(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y, -1);
}
else if (e.shiftKey) {
if (e.altKey) sphere.hiddenFun1(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
else sphere.pan(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
}
else {
sphere.rotate(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
}
dragOrigin.x = e.clientX;
dragOrigin.y = e.clientY;
}
function dragStopHandler(e) {
document.removeEventListener('mousemove', dragHandler);
document.removeEventListener('mouseup', dragStopHandler);
document.removeEventListener('mouseout', dragStopHandler);
canvas.removeEventListener('mouseout', stopPropagation);
document.body.removeEventListener('mouseout', stopPropagation);
}
function stopPropagation(e) {
e.stopPropagation();
}
So even that wasn't too bad! But then I realized I had forgotten about...
Touch support for mobile ðŸ˜
After implementing my updated code, I visited the page on my phone; nothing worked. Then it hit me: phones don't have mouses! 🤦
Some quick research reminded me of the relatively recent pointer
events (pointermove
, pointerup
, etc.), which are made to unify lots of different kinds of pointer-ish interactions: mouse, touch, stylus, new stuff we haven't thought of yet. The problem (for now) is that mobile support is spotty: pointer
events work on Chrome for Android, Opera Mobile, Android Browser, Samsung Browser, and IE Mobile (???), but not Firefox for Android, iOS Safari, or most other mobile browsers..
The other problem is that the controls to do anything other than spin the sphere currently require modifier keys on the keyboard to be pressed (Shift+drag to pan, Ctrl+drag to zoom). So pointer
events on their own will only enable rotating, nothing else.
A proper solution would be to learn about touch
events and how to implement gestures like pinch-zoom and 2-finger-pan, but I'll be honest, I looked into it a little and it's so complex! More than I want to get into right now. So the pointer
events will have to do as a half-measure that only fixes rotating and only works on some mobile devices. And I'm not thrilled about it 😞
Anyway, here's what all of that looks like. I'm testing for pointer
event support by checking whether the <body>
has an onpointermove
property, and if not I fall back to mouse
events, since there are desktop browsers that don't support pointer
events, too (looking at you, Safari 😡).
// Prefer 'pointer' events when available
const pointerEvent = (
'onpointermove' in document.body
? 'pointer'
: 'mouse'
);
// Shorthands for 'mouse' or 'pointer' events
const [downEvt, upEvt, moveEvt, outEvt] = (
['down', 'up', 'move', 'out']
.map(evtType => pointerEvent + evtType)
);
// Drag events
let dragOrigin;
canvas.addEventListener(downEvt, dragStartHandler)
function dragStartHandler(e){
dragOrigin = {
x: e.clientX,
y: e.clientY
};
document.addEventListener(moveEvt, dragHandler);
window.addEventListener(upEvt, dragStopHandler);
window.addEventListener(outEvt, dragStopHandler);
canvas.addEventListener(outEvt, stopPropagation);
document.body.addEventListener(outEvt, stopPropagation);
}
function dragHandler(e){
if (e.ctrlKey || e.metaKey) {
if (e.altKey) sphere.hiddenFun2(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
else sphere.zoom(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y, -1);
}
else if (e.shiftKey) {
if (e.altKey) sphere.hiddenFun1(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
else sphere.pan(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
}
else {
sphere.rotate(e.clientX-dragOrigin.x, e.clientY-dragOrigin.y);
}
dragOrigin.x = e.clientX;
dragOrigin.y = e.clientY;
}
function dragStopHandler(e) {
document.removeEventListener(moveEvt, dragHandler);
document.removeEventListener(upEvt, dragStopHandler);
document.removeEventListener(outEvt, dragStopHandler);
canvas.removeEventListener(outEvt, stopPropagation);
document.body.removeEventListener(outEvt, stopPropagation);
}
function stopPropagation(e) {
e.stopPropagation();
}
By the way, while working on this I discovered that Chrome's dev tools are very nice for emulating mobile devices with touch events!
Oh, one final thing. I had to add a CSS rule to the canvas to make touch work properly: touch-action: none;
. This basically prevents the browser from capturing touch events to try and scroll the page, and lets me use them in my JavaScript.
Finished product
It was a lot of iteration, and to be honest I tweaked it a bunch more while writing this article, but it's out there! Here's what it looks like with all the updates:
Conclusion
There are still things I'd like to fix. The biggest issue I have is that it's not mobile friendly, really at all. Not only do zoom and pan not work on mobile, but the webpage itself is almost completely non-responsive. I'd like to add some CSS to handle especially small page sizes more cleanly. Maybe I'll tweak it some more after this 🤔 As I said at the top, I'd love any feedback you might have! Heck, if you're feeling generous, make a PR against the repo!
But here's my overall takeaway: HTML, CSS, and JavaScript have improved a ton in the last 7 years, and so have I as a developer. Experience is an amazing thing!
In the final part of this series, which I'll write...eventually, I'll walk through the JavaScript itself and talk about how it works. That one will really be a standalone tutorial on how to build a thing with <canvas>
in vanilla JavaScript, not really dependent on these previous two posts.
Posted on April 12, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.