This project is first and foremost, my playground, where multiple ideas are tried and tested and are fully leveraged elsewhere. I initially wanted to just make a quick spirograph generator, but then stumbled onto various (exciting and frustrating) rabbit holes, each hole an entire universe worth exploring on their own. They result in, dare I say, a cacophony of ideas, of trials and errors, of exploratory implementations lacking ample due dilligence 😛.
There are some features that I eventually stopped working on because it's been almost two years of me just pausing and picking up where I left of, like the backend integration (sorry, you only get local storage for now), some fancy UI upgrades and smaller screens support. This project was burning me out, and I needed to finish it fast.
I am still undecided on why I like spirographs so much. I never grew up with them, nor have I played with a real one. It might be that it is one of not so many things that gives out ridiculously beautiful shapes with so little math. Anyway, enough with the intro, let's see what random stuff I thought worth being documented.
This spirograph generator contains 2 modes:
-
The animated mode draws an n-nested levels of cycloids at 60fps. This is done on the main thread, so the frames could dip significantly if your CPU is not very powerful for single-threaded operations. I didn't take the time to optimize it as the allure of this next feature was too strong.
-
The instant mode, is whehre fun stuff happens. This mode draws very, very fast. It also guarantees that for a Global Time Scale of 1 (description below), the drawn shape is guaranteed to be a complete, closed-shape (ex. an ellipse, not a U-Shaped curve). The algorithms are explored below, along with other implementation details.
rodLengthScale
: 1 means the rod is the same length as the cycloid it belongs to. This rod extends the physical boundary in that for a real spirograph, you cannot put your pencil outside of a circle, only inide of it. The position of traced path cannot be more than c.x + c.r or c.y + c.r, where x and y are the circle's origin, and r the radius of the circle.
cycloidSpeedScale
: the ratio of the surface covered as the child cycloid moves around the parent. The value of 1 means there are no sliding (physically accurate).
moveOutsideOfParent
: whether the curent cyclod is positioned within or outside of its parent cycloid.
radius
: the radius of the current cycloid.
rotationDirection
: orbit direction, left or right.
selectedCycloid
: the cycloid whose parameters you would like to change.
globalTimeStep
: controls the iterations needed until an image is fully drawn. The higher the value, the lower the iterations...and resolution. However, set the value too low, and the image will take too long to be drawn in the animated mode, and use more processing power in the instant mode (more iterations, so, still longer, but not as long as the animated mode).
clearTracedPathOnParamsChanged
: whether or not to clear the already-traced paths in animated mode when the parameters are changed.
outerBoundingCircleRadius
: the radius of the base circle.
showAllCycloids
: whether or not to show all cycloids (the circles). Works only in the animated mode.
traceAllCycloids
: whether or not to trace the paths of all cycloids. Works only in the animated mode.
displayedCycloid
: the cycloid whose path is currently being traced.
The simplest of the two modes. This mode adds up the position of the current point for every frame based on the current parameters of each cycloid, adding one on top of another. Nothing much to mention, I didn't even bother optimizing this (moving it to another worker thread and whatnot). The math behind this is simple and will be explained in the next part. Fun things happen there, not here!
This is where most of the work went. The rendering is done in the worker thread and painted onto an offscreen canvas. Wasm and WebGL are both used here alongside a very interesting algorithm(for me) to help make sure that we get the full, or the contour of the shape, depending on the globalTimeStep
property, as fast as possble.
A brief overview of how it's done, more detial below: grab all properties from the main thread, pass them all to the worker thread, the worker thread then sends the parameters to Rust to calculate how many points to draw for a complete shape, then calculates the position for each of the points, again, with Rust, and then pass that back to JavaScript. Now, with the positions of the vertices available in memory -- hopefully not too many points until the heap oveflows :p -- we pass all of that to WebGL's drawArrays
and all lines are drawn at once.
The problem with the numerical method like the one in the animated mode is that it's long, and you don't know when it'll end, or if the end is really the end?! Too few iterations and the shape won't be complete, and too many means wasting computation time.
Let's say we check
if (currentPoint.x === beginningPoint.x && currentPiont.y === beginningPoint.y) {
draw();
}
every iteration. What will happen is that for shapes other than simple geometric 2D shapes, this will most likely not result in a full shape. Given complex enough parameters, the traced line will cross itself before the shape completes.
Okay, let's say we now just keep drawing for a million iterations, that'll for sure get the complete shape, right? Right, but we do not want to over draw a shape, because our goal here is to draw complex and beautiful shapes as fast as possible.
Our solution would be to find out how many iterations each shape need based on the existing parameters.
Let's begin with the formula. I'll be showing the implementation both in code and math.
This is the set up code, anything I show further is implied that it's done within the second script tag. Copy and paste the following snippet in an html file if you are interested, else feel free to just read through.
Nevermind what they do. We'll only be using two functions, begin
, and drawCircle
.
<!-- Just copy and paste this boilerplate code -->
<!DOCTYPE html>
<html lang="en">
<head>
<style>
html,
body,
canvas {
border: 0;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
</body>
<script>
const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth * devicePixelRatio;
canvas.height = window.innerHeight * devicePixelRatio;
const ctx = canvas.getContext("2d");
const screenCenter = {
x: canvas.width / 2,
y: canvas.height / 2,
};
function begin(callback, clearCanvas = true) {
requestAnimationFrame((theta) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (clearCanvas) {
callback(theta * 0.001);
}
begin(callback, clearCanvas);
});
}
function drawCircle({ x, y, r }, fill = false) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
if (fill) {
ctx.fill();
} else {
ctx.stroke();
}
}
</script>
<script>
// Stuff below goes here.
</script>
</html>
Let's grab a point of size
// Create 2 radii, can be of any size, just make the first one larger than the second.
const r0 = 600;
const r1 = 50;
// The set up code provides a simple interface that passes a delta time as the parameter, we'll use it to draw a rotating circle at the center of the screen.
begin((theta) => {
drawCircle({
x: screenCenter.x + Math.cos(theta) * (r0 + r1),
y: screenCenter.y + Math.sin(theta) * (r0 + r1),
r: r1,
});
});
If we draw the base circle, we'll see that the orbiting circle is sliding on the edge the edge of the base circle, rather than inside or outside. Let's make it move out for now.
const r0 = 600;
const r1 = 50;
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
// draws the base circle.
drawCircle({
x: baseCirclePosition.x,
y: baseCirclePosition.y,
r: r0,
});
// referencing baseCirclePosition instead of the center of the screen
// Math.cos(theta) * r0 is the base circle
// Math.cos(theta) * (r0 + r1) is the base circle plus the radius of the rotating circle.
drawCircle({
x: baseCirclePosition.x + Math.cos(theta) * (r0 + r1),
y: baseCirclePosition.y + Math.sin(theta) * (r0 + r1),
r: r1,
});
});
Having two is nice, but let's try having 3 levels. I'll reduce the radius of the base circle a bit as well.
While drawing the second circle, we can imagine a long rod extending from the middle of the base circle to the center of the second circle. With this in mind, we can say that the second circle's center is equal to the base circle's position (which is just the screen center, or 0) plus the equation above.
This means that the third circle's position of radius
const r0 = 300;
const r1 = 50;
const r2 = 20;
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
// trace inner
drawCircle({
x: baseCirclePosition.x,
y: baseCirclePosition.y,
r: r0,
});
// trace mid
const midCirclePosition = {
x: baseCirclePosition.x + Math.cos(theta) * (r0 + r1),
y: baseCirclePosition.y + Math.sin(theta) * (r0 + r1),
};
// trace outer
drawCircle({ x: midCirclePosition.x, y: midCirclePosition.y, r: r1 });
const outerCirclePos = {
x: midCirclePosition.x + Math.cos(theta) * (r1 + r2),
y: midCirclePosition.y + Math.sin(theta) * (r1 + r2),
};
drawCircle({ x: outerCirclePos.x, y: outerCirclePos.y, r: r2 });
});
We now see a recurring pattern and can generalize a bit with a for loop. An example with only p.x
const radii = [300, 50, 20];
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
let sum = {
x: baseCirclePosition.x,
y: baseCirclePosition.y,
};
// trace the base outside of the loop.
drawCircle({
x: sum.x,
y: sum.y,
r: r0,
});
for (let i = 1; i < radii.length; i++) {
sum.x += Math.cos(theta) * (radii[i - 1] + radii[i]);
sum.y += Math.sin(theta) * (radii[i - 1] + radii[i]);
drawCircle({
x: sum.x,
y: sum.y,
r: radii[i],
});
}
});
The result is the same as above.
With our new generalization, adding a new outer level is as simple as adding a new number to the radii
array.
// 5 levels
const radii = [300, 150, 75, 50, 30];
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
let sum = {
x: baseCirclePosition.x,
y: baseCirclePosition.y,
};
drawCircle({
x: sum.x,
y: sum.y,
r: radii[0],
});
for (let i = 1; i < radii.length; i++) {
sum.x += Math.cos(theta) * (radii[i - 1] + radii[i]);
sum.y += Math.sin(theta) * (radii[i - 1] + radii[i]);
drawCircle({
x: sum.x,
y: sum.y,
r: radii[i],
});
}
});
If we trace out the path of the outermost circle, we'll now have...just a bigger circle. Not very exciting.
With HTML5 Canvas, we can trace the path by having another canvas that we send the draw data to, but that's a bit too much for this tiny demonstration, so we'll just trace only the outermost circle and stop clearing the canvas.
const radii = [300, 150, 75, 50, 30];
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
let sum = {
x: baseCirclePosition.x,
y: baseCirclePosition.y,
};
for (let i = 1; i < radii.length; i++) {
sum.x += Math.cos(theta) * (radii[i - 1] + radii[i]);
sum.y += Math.sin(theta) * (radii[i - 1] + radii[i]);
if (i === radii.length - 1) {
drawCircle({
x: sum.x,
y: sum.y,
r: radii[i],
});
}
}
// pass false to stop clearing the canvas
}, false);
Now for the really exciting part. The rotation of every circle is synced right now. They all share the same theta value. If we use different scalar values
const radii = [300, 150, 75, 50, 30];
const lambdas = [1, 2.2, 3.5, 2.234, 4];
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
let sum = {
x: baseCirclePosition.x,
y: baseCirclePosition.y,
};
for (let i = 1; i < radii.length; i++) {
sum.x += Math.cos(theta * lambdas[i]) * (radii[i - 1] + radii[i]);
sum.y += Math.sin(theta * lambdas[i]) * (radii[i - 1] + radii[i]);
if (i === radii.length - 1) {
drawCircle(
{
x: sum.x,
y: sum.y,
// Make the radii a bit smaller
r: 10,
// pass true to fill instead of stroke
},
true
);
}
}
// pass false to stop clearing the canvas
}, false);
Now our equation is almost complete! All that's left is to just make sure that our circles can rotate in both directions. Making something rotate in the other direction is simply switching sin
for cos
and vice versa for both x
and y
.
The difference between a sin
and a cos
is only
This means that we can just assign a constant
This is made easy by one of JavaScipt's quirks. true
is 1 and false
is 0, so we can just multiply the offset by the boolean parameter to have the circle rotate clockwise or counterclockwise.
const radii = [300, 150, 75, 50, 30];
const lambdas = [1, 2.2, 3.5, 2.234, 4];
const isClockwise = true;
const baseCirclePosition = { x: screenCenter.x, y: screenCenter.y };
begin((theta) => {
let sum = {
x: baseCirclePosition.x,
y: baseCirclePosition.y,
};
for (let i = 1; i < radii.length; i++) {
sum.x +=
Math.cos(theta * lambdas[i] - (Math.PI / 2) * isClockwise) *
(radii[i - 1] + radii[i]);
sum.y +=
Math.sin(theta * lambdas[i] - (Math.PI / 2) * isClockwise) *
(radii[i - 1] + radii[i]);
if (i === radii.length - 1) {
drawCircle(
{
x: sum.x,
y: sum.y,
r: 10,
},
true
);
}
}
}, false);
Going clockwise or counterclockwise gives out different shapes. Give it a try!
With all of our building blocks complete, the equation is now
Where
Overall, it's very simple to derive, and the code is not too complicated (I did the final code in Rust to optimize some stuff, but core idea is still the same).
Okay. Now we know how to sum up the final position of our points. But we still don't know how many iterations we need.
Let's first think about each rotation as just 2 different pairs of 2 trig functions
In the case of a cosine, the second time
Does this mean, then, that we can just keep looping until we hit
const stepsNeededForEachIteration = 60;
const k = Math.PI / 2 / stepsNeededForEachIteration;
const thetaScale = 0.289;
const phiScale = 0.948;
const whateverGreekLetterIsNextScale = 2.99;
while (
Math.cos(theta * thetaScale) !== 1 &&
Math.cos(theta * phiScle) !== 1 &&
Math.cos(theta * whateverGreekLetterIsNext) !== 1
) {
draw();
theta += k;
}
Of course not. Like, we are doing numerical integration. There is a chance
There are actually two things that could help us here from our elementary math classes: gcd and lcm.
We would like to find out basically when
A refresher: lcm returns a number that is divisible by each of the numbers passed as argument, and gcd returns the number that all input numbers are divisible by.
lcm(8, 12); // 24
lcm(2, 5, 6); // 30
lcm(10, 100, 200); // 200
gcd(8, 12); // 4
gcd(2, 5, 6); // 1
gcd(10, 100, 200); // 10
Let's try to predict when three cosine functions collide. This time, we'll do it with numbers less than one. Reason being...that's how I started.
Let's see, when do you think
If we look at them as fractions, things become much easier:
For this set of numbers, if we inspect the graph and compare all of them against
Now, I'm pretty sure this is not how people prove their mathmatical reasoning, but I can clearly see that our base oscillation of lcm
of the denominator.
lcm(2, 4); // 4
lcm(2, 4, 8); // 8
So let's try turning our decimal numbers into fractional number and take the lcm
of the denominators.
const result = lcm(numbers.map((n) => getDenominator(n)));
// https://www.mathsisfun.com/converting-decimals-fractions.html
function getDenominator(n) {
// If an integer, the denominator is one.
if (n % 1 === 0) return 1;
// find out how many decimal place this number has.
const decimalPlaceCount = n.toString().split(".")[1];
const numerator = Math.pow(10, decimalPlaceCount);
const denominator = numerator * n;
const divisor = gcd(numerator, denominator);
// We don't care about the simplified numerator.
denominator /= divisor;
return denominator;
}
And this works for all numbers?? Yes it does!. However, we are doing gcd
twice (the lcd
algorithm includes calculating gcd
), let's try to make it a bit easier.
So if we walk through the calculation.
Give the numbers to the function.
Turn into decimals.
Find the divisor with gcd
and simplify.
Then take the lcm
of the two denominators.
Which is basically
Did you notice something? I didn't. My math genius friend had to point it out to me. In the second steps, we already have all the information we need, after having turned everything into decimals.
The final result is actually just the result of
When we had only 0.5 and 0.25, it was
So apparently, it is the result of the one of the max dedcimal place of all numbers divided by some numbers
The max decimal of the 3 is 0.125, so 3 zeros.
Then
All done!
In short, what we did above was, for all numbers gcd
of those numbers.
Now we have the number of points we neeed and the way for us to some up the final position of each of the point to be drawn. Now we can just loop over those points and begin drawing!
// For a more detailed version, look at calc_points/lib.rs
// We need to compensate a bit, based on how fast, or slow the animation is.
const times = compensate(result, timeStepScalar);
let theta = 0;
for (let i = 0; i < result; i++) {
theta += step;
let p = sumUpPoints(cycloids);
draw(p);
}
That looks good and all, but there is yet another problem. Drawing for thousands, tens or hundreds of thousands, or a million times is slow...very slow.
So far, I didn't talk about how we implement the draw
method. If it were to be implemented like this:
function draw(p) {
ctx.beginPath(p);
ctx.moveTo(p.previuos.x, p.previous.y);
ctx.lineTo(p.current.x, p.current.y);
ctx.stroke();
}
it will just take forever. We need WebGL to help us solve this problem.
I wrote a small custom-made webgl renderer because other options are too generic and would definitely bloat the project. This renderer does nothing but render a bunch of lines with and API that is not too complicated. My rendering use case is very simple, I only need 3 methods:
export default interface Renderer {
render(): void;
resize(newWidth: number, newHeight: number): void;
setTransformation(mat: { x: number; y: number; z: number }): void;
}
The render
method is called every time there is a change to the parameters, a resize happens, a transformation is applied to the matrix (zoom, pan), or the focused cycloid has changed. I could have gone with caching the rendered output and added a debounce or throttle wrapper to minimize the rendering time, but doing that would mean losing the ability to see the change animating as the parameters change in real-time. Instead, the globalTimeStep
property can be used to help improve the performance, when the rendering takes too long.
In the render
method, I used webGL's drawArrays
to help me draw all the lines (2 points) all at once. This means that I have to put the points into an array and then pass them all to the renderer.
const points = [];
const times = compensate(result, timeStepScalar);
let theta = 0;
for (let i = 0; i < result; i++) {
theta += step;
let p = sumUpPoints(cycloids);
points.push(p);
// draw(p) << No longer needed
}
// ...
renderer.render(points)
render(points: Point[]) {
// ...
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array(points),
gl.STATIC_DRAW
);
const linesCount = points.length / 2;
gl.drawArrays(gl.LINES, 0, linesCount);
}
Now we have reduced the rendering time from seconds to a tenth or a hundredth of milliseconds for most render. That is more than a thousand times faster!
A cool trick I found while building the renderer is that using a debouncer with a delay of 0 will make your animation run smoother as it "silently" discards render calls that comes in too fast. In other words, a group of consecutive mousemove events are aggregated and the render
method is invoked just once.
I know deboucning is not new, but I have never thought about how debouncing could be useful when its interval is 0. Literally, the core concept is to postpone (aggregate, whatever) events until after a certain interval. While 0 might sound like we simply never postpone...we actually kind of do. Let's look at some code.
This is the rendering code in a nutshell.
// In the renderer class.
timeoutHandle = null;
/**
* If this is our task queue
*
* Queue: [mousemove, mousemove, mousemove, mousemove, mousemove]
*
* deboucning it with an interval of 0, the queue becomes
*
* Queue: [render] (the last one, the one after cancels out the one before)
*
* but when we don't debounce, we get.
*
* Queue: [render, render, render, render, render]
*
*/
render() {
// Clearing the timeout cancels the previously-registered task.
clearTimeout(this.timeoutHandle);
this.timeoutHandle = setTimeout(super.render, 0);
}
When the user moves the cursor too fast, the mousemove events will come in droves. We call the render
method for every mouse event. If we do not aggregate our events, those x
number of mouse events, where x
is some relatively beefy numbers like 10, will trigger exactly 10 rendering calls -- really expensive. If we aggregate, those 10 events can become just 1 render call.
This helps a lot with complex shapes as it reduces the total rendering time, especially when the user pans very fast.
Instead of rendering every point from e.x = 10
to e.x = 20
, the system might render only e.x = 10
and once again at e.x = 20
. Let's say each render takes exactly 1 second, the first case means the total render time is 10 seconds, while the second is just 2 seconds. Sure, we missed frames, but the system will feel A LOT more responsive, which is actually (subjectively) more important.
Now, if you're unlucky, some of the events that slipped through setTimeout
will not be displayed on screen. They will be "rendered", but not "displayed". We need to sync the frame rate of the screen to the render events. We can do this by buffering each render with requestAnimationFrame
like so:
render() {
clearTimeout(this.timeoutHandle);
this.timeoutHandle = setTimeout(() => requestAnimationFrame(super.render), 0);
}
In most cases, this won't happen. We have already debounced a lot of the consecutive events, so the ones that slipped through won't really be that big of a deal.
Notice how those delays cause our events to accumulate, which cause event more delays.
Why Rust-WASM and not just JavaScript, you may ask.
After having profiled multiple times (like a lot), WASM seems to gives better results when dealing with lower iterations. With larger iterations (~500,000 or more), JavaScript's JIT compiler comes in and steals the victory. With globalTimeStep being an option, and most combinations of cycloidSpeedScale result in iterations less than the limit that I found, I see it fitting that we go with Rust for this small calculation util.
Hopefully, future improvements made to WASM will also give this function some performance boost as well.
The wasm modules can be in charge of finding out how many points we need to draw, and the positions for each of the points. Had we not moved the rendering to WebGL, this wouldn't have been possible. We would have needed to call a draw
method within each loop. But now that we don't, we can do this:
// shortened version of calc_lines/lib.rs
// ...some set up code
for _ in 0..points {
new_point = compute_epitrochoid(
data_for_computation
);
if first_time {
first_time = false;
} else {
arr.push(prev_point[0]);
arr.push(prev_point[1]);
arr.push(new_point[0]);
arr.push(new_point[0]);
}
prev_point = new_point;
theta += step;
}
arr // return result to JavaScript
Could I have made this feature in JavaScript? Yes! Actually, the feature was made in JavaScript and then I later switched to Rust. JavaScript was actually not slow at all and I'm sure that had I not made the changed, the rendering would appear to the eyes just as fast. But, hey...I learned Rust! (and I get to put WASM in my portfolio :p)
Alright, almost at the end. There's a bit more to do.
So our drawn spirograph can get very, very intricate. Sometimes, we just want to be able to zoom in and see stuff!
Those four corners, top, left, button, right, looks really interesting. Would be nice if we could do something like this.
Oh that's right. We CAN already do that! I am building another zoom and pan library though. That is a rabbit hole worth exploring on its own. This article is already too long!
Ok one last thing.
There is this thing buggy feature that I didn't manage to finish. I was so tired of this project and wanted to move on. However, there are some things I think might be worth talking about though.
The relationship editor allows you to re-define the parent-child cycloid between each of the cycloid. This definitely called for some kind of tree-like data structres.
animated mode
instant mode
I must admit I have made a mistake of drawing it with SVG. This made the interaction difficult to handle because now I have to involve the DOM API and CSS. This involved another level of complication to the logic + some bugs (that I didn't bother fixing).
I have a generateNode
hook that is called everytime the control is switched to a relationship editor. Within the hook, I have a sort of a NodeLevel manager that keeps track of the "level" each node resides in. The level is determined by having each node recursively travel up its chain until it meets the base node, the outerBoundingCircle
.
// getDrawLevel.ts
// I removed the error-handling code to make this a bit more readable.
function getCurrentDrawLevel(
parentId: number,
cycloidControls: React.MutableRefObject<CycloidControls>,
levelCounter: number,
currentId: number,
cache: Record<number, number> // For memoization
): number {
// If has gone through this parent before, return immediately with the parent's level + the counter we got so far.
if (cache[parentId]) {
return levelCounter + cache[parentId];
}
// -1 is the base level.
if (parentId === -1) {
cache[currentId] = levelCounter;
return levelCounter;
}
// grab parent and grandparent param for the recursive call.
let parentParams =
cycloidControls.current.cycloidManager.getSingleCycloidParamFromId(
parentId
);
const grandParentIndex = parentParams!.boundingCircleId;
return getCurrentDrawLevel(
grandParentIndex,
cycloidControls,
levelCounter + 1,
currentId,
cache
);
Once we know the level of each nodes, we specify an arbitrary space between each level, calculate the positions, and then we can begin placing them.
To place the circles like this:
We first need to be able to do this:
For any
for (let i = 0; i < thisLevelNodeCount; i++) {
// xPos is just placing the nodes out with nodeMargin * i margin, starting from center.
const xPos = nodeCenter.x + nodeMargin * i;
// xOffset is the offset we need to do to get the row to be in the center.
// It should be half the full span of the row, which is:
const xOffset = (nodeMargin / 2) * (thisLevelNodeCount - 1);
ctx.beginPath();
ctx.arc(
xPos - xOffset,
nodeCenter.y + someYOffset,
someRadius,
0,
Math.PI * 2
);
ctx.fill();
}
And then we'll just have to make sure that when we place the nodes, each child stays close to their parents so that we can draw a straight line from it to its parent without crossing over other higher-level nodes.
nodesOnLevel.sort((a, b) => (a.parentId ?? -1) - (b.parentId ?? -1));
When we traced the lines with a lot of nodes, they sometimes look funny. But that is good enough for our simple use case.
Let's do another interactive set of examples.
Here's the boilerplate, paste this in an html file.
The boilerplate this time is quite a lot. Ignore them and focus on the TODO section at the bottom. They'll help us focus only on drawing the lines.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<style>
html,
body,
canvas {
border: 0;
padding: 0;
margin: 0;
}
canvas {
width: 100%;
height: 100%;
display: block;
}
</style>
</head>
<body>
<div><canvas id="canvas"></canvas></div>
</body>
<script>
let canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
var width = window.innerWidth;
var height = window.innerHeight;
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
const LevelFactory = {
levels: [],
newLevel: function () {
const level = {
nodes: [],
addNode: function (args) {
const newNode = args?.parent
? { parent: args.parent, x: 0, y: 0 }
: { parent: null };
newNode.children = [];
this.nodes.push(newNode);
return newNode;
},
};
this.levels.push(level);
return level;
},
config: {
center: {
x: canvas.width * 0.5,
y: canvas.height * 0.1,
},
margin: {
x: 180,
y: 200,
},
radius: 30,
},
buildNodes: (level1Count) => {
// lv0
const level0 = LevelFactory.newLevel();
const rootNode = level0.addNode();
// lvl1
const level1 = LevelFactory.newLevel();
for (let i = 0; i < level1Count; i++) {
level1.addNode({ parent: rootNode });
}
// Add here for more levels
},
drawNodes: function (count, ctx, lineBuilder) {
this.buildNodes(count);
const { radius, center, margin } = this.config;
for (let i = 0; i < this.levels.length; i++) {
const level = this.levels[i];
for (let j = 0; j < level.nodes.length; j++) {
const xPos = center.x + margin.x * j;
const xOffset = (margin.x / 2) * (level.nodes.length - 1);
const n = level.nodes[j];
n.x = xPos - xOffset;
n.y = center.y + margin.y * i;
n.r = radius;
ctx.beginPath();
ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2);
ctx.fill();
if (n.parent) {
n.parent.children.push(n);
const { bottom, top } = lineBuilder(n);
ctx.beginPath();
ctx.moveTo(bottom.x, bottom.y);
ctx.lineTo(top.x, top.y);
ctx.strokeStyle = "red";
ctx.stroke();
}
}
}
},
};
</script>
<!-- Everything above is just a set up code, ignore them. -->
<script>
let childNodeCount = 1;
/**
* Node definition.
*
* Node: {
* parent: Node,
* children: Node[]
* x: number,
* y: number,
* r: number
* }
*/
LevelFactory.drawNodes(childNodeCount, ctx, (node) => {
// TODO!!!
return {
top: {
x: 0,
y: 0,
},
bottom: {
x: 0,
y: 0,
},
};
});
</script>
</html>
Open the html file in a browser of your choice and then you should see this.
The simplest way to draw a line between the two would be to draw from the center of the parent to the center of the child.
LevelFactory.drawNodes(childNodeCount, ctx, (node) => {
const parent = node.parent;
return {
top: {
x: parent.x,
y: parent.y,
},
bottom: {
x: node.x,
y: node.y,
},
};
});
childNodeCount = 8; // increase it a bit to some number more than 1
This works. But on the web, my nodes will be stroked, not filled, so, the lines must not cross the edge of the circle.
We can offset the parent's y
by the its radius
LevelFactory.drawNodes(childNodeCount, ctx, (node) => {
const parent = node.parent;
return {
top: {
x: parent.x,
y: parent.y + parent.r,
},
bottom: {
x: node.x,
y: node.y - node.r,
},
};
});
What I want now is for the lines to slowly climb along the side of the parent circle as the number of children increases. Right now every single line from each child connects directly to the bottom center of parent.
me.status !== Status.satisfied 😡.
Let c.x
and c.y
be the point of the line under the parent and p.x
and p.y
be the point above our child circle. We can say that p
in both axes is influenced by the x
position of c
. Let's focus on the x-axis first, as it's the easiest.
We can begin by lerping the x
position.
// k can be anything. < 0.5 looks nice.
const k = 0.2;
top: {
x: x2 + (x1 - x2) * 0.2;
}
Then clamp the value so that the line never extend beyond the diameter of the parent.
const k = 0.2;
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const finalX = clamp(x2 + (x1 - x2) * k, x2 - parent.r, x2 + parent.r);
return {
top: {
x: finalX,
y: y2 + parent.r,
},
bottom: {
x: x1,
y: y1 - node.r,
},
};
Now we have another problem. Because a lot of them spread beyond the diameter of the circle, while only a couple remained inside. With the clamping applied, the connections to the parent look unequally distributed. We'll come back to this later when the problem is more pronouned.
Now let's take a look at how we can solve for y
.
We need to make sure that the y offset is such, that p.x
and p.y
is always equal to some point of the parent circle's edge. We need something that will help keep our y-offset in sync with the curvature of the circle.
This calls for an equation that I never thought I had to use: the circle equation.
This old friend from elementary school describes the relationship between the center point of a circle (h, k)
, any point on the circle (x, y)
, and the circle's radius r
.
We already know everything in that equation except y
. So here's the same equation, but solved for y
.
We have two equations, one for the top half, and the other for the bottom half of the circle. What we are doing petains the bottom half, but the canvas's y
axis is flipped, so we'll use the top one.
We have to be careful though; the value inside the square root will become negative when the tip of the line crosses one half into the other.
This is a bit difficult to visualize, let's take a look at this desmos demo.
The domain of each of the equation is only defined for half of the circle. If we cross the line without switching the equation, it'll become
There might be more elegant math solutions, but for now, we'll just use JavaScript's Math.max
to help make sure that we stay on the bottom side of the circle.
Plug that into our code we get:
LevelFactory.drawNodes(childNodeCount, ctx, (node) => {
const parent = node.parent;
const x1 = node.x;
const y1 = node.y;
const x2 = parent.x;
const y2 = parent.y;
const k = 0.2;
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const finalX = clamp(x2 + (x1 - x2) * k, x2 - parent.r, x2 + parent.r);
// The new part
const finalY =
Math.sqrt(
Math.max(
0,
-Math.pow(x2, 2) +
2 * x2 * finalX +
Math.pow(parent.r, 2) -
Math.pow(finalX, 2)
)
) + y2;
return {
top: {
x: finalX,
y: finalY,
},
bottom: {
x: x1,
y: y1 - node.r,
},
};
});
Now it is very clear that the lines that connect to the parent are not evenly distributed with clamping. We need to make their x positions pan out slower.
From this equation, let's extract out (x1 - x2) * k
and store it somewhere else. We'll scale their result because they are responsible for offsetting the x
coordinate of the points at the top.
const k = 0.2;
const offsetX = (x2 - x1) * k; // moved here
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const finalX = clamp(x2 + offsetX, x2 - parent.r, x2 + parent.r);
There are many approaches we could take for this. We'll look at a few of them.
With linear interpolation, we can be sure that every single line will have the same amount of space in the x axis between them. This looks good for multiple lines.
But not so much when we have just a few lines.
The points don't look 100% evenly distributed, even though they do. This because we're working with a circle. In a circle, we have to lerp both x and y.
But even lerping the x is already superfluous. No one is going to add 16 nodes to the editor. Let's not go further.
I have drawn more lines to show that the points are equally distributed in the x-axis, but the angles of each point, as vectors extending from the center of the circle, are not.
const k = 0.2;
let offsetX = (x1 - x2) * k;
// The underappreciated remap formula :(
// https://youtu.be/NzjF1pdlK7Y
const remap = () => {
// meta.absoluteMin and meta.absoluteMax are the x position of node n0 and n1 in the current level.
// I wrote them just for this linear interpolation example.
const oldMax = meta.absoluteMax;
const oldMin = meta.absoluteMin;
const newMax = x2 + parent.r;
const newMin = x2 - parent.r;
const oldVal = x1;
const t = (oldVal - oldMin) / (oldMax - oldMin);
const newValue = newMin + (newMax - newMin) * t;
return newValue;
};
const finalX = remap();
For this project, most of the time, a node won't have more than a few children. The best shapes come from linking all the nodes together into one long chain, not linking all nodes to one parent.
What we want to do is to slow the offsetX
value. If we can slow it down, they won't be cluttered together in those couple of spots.
There are so many options, but what I arbitrarily found to be quite nice is
And that's it!
const k = 0.2;
let xOffset = (x1 - x2) * k;
let sign = Math.sign(xOffset);
xOffset = Math.pow(Math.abs(xOffset), 0.65) * sign;
const clamp = (n, min, max) => Math.min(Math.max(n, min), max);
const finalX = clamp(x2 + xOffset, x2 - parent.r, x2 + parent.r);
That was a lot of work for a feature that I didn't even complete (still quite buggy). But it was a fun little side-project within a side-project.
So, this has been more of an exploratory endeavor than anything else.
I don't think I will be using a lot of what I learned here. I have to admit that the many aspects of the project were so niche at times.
Nontheless, the journey down this rabbit hole have introduced me to several cool tricks and discoveries I would not have otherwise, if ever, stumbled upon.
All the math and the implementation details I talked about could have been left out of this monolithic blog and you will still get the idea, but I like diving deep and deep-diving talks/blogs really tick all my learning boxes, so I figured I'd make one as well.
I hope this journey has been as enlightening for you as it has been for me. See you on the next one.