Drawing a star with DOMMatrix
I recently recorded an episode of HTTP 203 on DOMPoint
and DOMMatrix
. If you'd rather watch the video version, here it is, but come back here for some bonus details on a silly mistake I made, which I almost got away with.
DOMMatrix
lets you apply transformations to DOMPoint
s. I find these APIs handy for drawing shapes, and working with the result of transforms without causing full layouts in the DOM.
DOMPoint
Here's DOMPoint
:
const point = new DOMPoint(10, 15);
console.log(point.x); // 10
console.log(point.y); // 15
Yeah! Exciting right? Ok, maybe DOMPoint
isn't interesting on its own, so let's bring in DOMMatrix
:
DOMMatrix
const matrix = new DOMMatrix('translate(10px, 15px)').scale(2);
console.log(matrix.a); // 2;
DOMMatrix
lets you create a matrix, optionally from a CSS transform, and perform additional transforms on it. Each transform creates a new DOMMatrix
, but there are additional methods that mutate the matrix, such as scaleSelf()
.
Things start to get fun when you combine DOMMatrix
and DOMPoint
:
const newPoint = matrix.transformPoint(point);
I used this to draw the blobs on Squoosh, but even more recently I used it to draw a star.
Drawing a star
I needed a star where the center was in the exact center. I could download one and check the center point, but why not draw it myself? A star's just a spiky circle right? Unfortunately I can't remember the maths for this type of thing, but that doesn't matter, because I can get DOMMatrix
to do it for me.
const createStar = ({ points = 10, x = 0, y = 0, size = 1 }) =>
Array.from({ length: points }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
.transformPoint({ x: 0, y: 0 }),
);
I'm using Array.from
to create and initialise an array. I wish there was a friendlier way to do this.
A typical star has 10 points – 5 outer points and 5 inner points, but I figured it'd be nice to allow other kinds of stars.
The matrix only has transforms set to apply the size and position, so it's just going to return a bunch of points at x, y
.
Anyway, I'm not going to let that stop me. I'm going to draw it in an <svg>
element below:
const starPoints = createStar({ x: 50, y: 50, size: 23 });
const starPath = document.querySelector('.star-path');
starPath.setAttribute(
'd',
// SVG path syntax
`M ${starPoints.map((point) => `${point.x} ${point.y}`).join(', ')} z`,
);
And here's the result:
So, err, that's 10 points on top of each other. Not exactly a star. Ok, next step:
const createStar = ({ points = 10, x = 0, y = 0, size = 1 }) =>
Array.from({ length: points }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
// Here's the new bit!
.translate(0, -1)
.transformPoint({ x: 0, y: 0 }),
);
And the result:
Use the slider to transition between the previous and new state. I'm sure you'll agree it was worth making this interactive.
Ok, so all the points are still on stop of each other. Let's fix that:
const createStar = ({ points = 10, x = 0, y = 0, size = 1 }) =>
Array.from({ length: points }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
// Here's the new bit!
.rotate((i / points) * 360)
.translate(0, -1)
.transformPoint({ x: 0, y: 0 }),
);
I'm rotating each point by a fraction of 360 degrees. So the first point is rotated 0/10ths of 360 degrees, the second is rotated 1/10th, then 2/10ths and so on.
Here's the result:
Now we have a shape! It's not a star, but we're getting somewhere.
To finish it off, move some of the points outward:
const createStar = ({ points = 10, x = 0, y = 0, size = 1 }) =>
Array.from({ length: points }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
.rotate((i / points) * 360)
// Here's the new bit!
.translate(0, i % 2 ? -1 : -2)
.transformPoint({ x: 0, y: 0 }),
);
Here's the result:
And that's a star!
But then I messed it up
As I was getting the slides together for the HTTP 203 episode, I realised that the points
argument wasn't quite right. It lets you do something like this:
const starPoints = createStar({ points: 9, x: 50, y: 50, size: 23 });
Which looks like this:
Which… isn't a star. The number of points has to be even to create a valid star. Besides, the ten-pointed shape we've been creating so far is typically called a "five-pointed star", so I changed the API to work in that style:
const createStar = ({ points = 5, x = 0, y = 0, size = 1 }) =>
Array.from({ length: points * 2 }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
.rotate((i / points) * 360)
.translate(0, i % 2 ? -1 : -2)
.transformPoint({ x: 0, y: 0 }),
);
I quickly tested the code, and it looked fine. But… it's not quite right. Can you see the bug I've introduced? I didn't notice it until Dillon Pentz pointed it out on Twitter:
Wait… If the index in the array from loop is
0 <= i < points * 2
, how does it produce the correct star when dividing by points? Doesn't that rotate around the circle twice?
And, he's right! I correctly multiply points
for the array length, but I forgot to do it for the rotate
. I didn't notice it, because for stars with odd-numbered points, it creates a shape that's almost identical.
The above is the result I intended. Drag the slider to see what the code actually does.
Here's a correct implementation:
const createStar = ({ points = 5, x = 0, y = 0, size = 1 }) => {
const length = points * 2;
return Array.from({ length }, (_, i) =>
new DOMMatrix()
.translate(x, y)
.scale(size)
.rotate((i / length) * 360)
.translate(0, i % 2 ? -1 : -2)
.transformPoint({ x: 0, y: 0 }),
);
};
I guess the moral of the story is: Don't change slides at the last minute.