Sacred Geometry
This project was inspired by Monument Valley 2, where at the end of some levels you get to draw a mandala. The line you draw gets repeated four more times rotated around the center and then mirrored. When you’ve finished drawing it all spins clockwise around the center, with the mirrored lines spinning the opposite direction. For my version of this I wanted it to be easy to just get drawing without preamble, so I decided on a webpage using a canvas.
<canvas id="canvas" width="1024" height="1024"></canvas>
I wanted to make sure it would work well on mobile, so I carefully designed the page with different screen sizes in mind and made sure touch would function correctly.
canvas.addEventListener("mousemove", event => {
if (drawing) {
draw(getPosition(event));
}
});
canvas.addEventListener("touchmove", event => {
if (drawing) {
event.preventDefault();
event.stopPropagation();
draw(getPosition(event.touches[0]));
}
});
function getPosition(event) {
const scale = canvas.width / canvas.clientWidth;
const x = (event.clientX - canvas.offsetLeft + window.scrollX) * scale;
const y = (event.clientY - canvas.offsetTop + window.scrollY) * scale;
return { x, y };
}
When drawing, the rotated positions for every frame of animation are calculated immediately.
const pathCount = 5;
const frameCount = 50;
function getRotatedPosition(position, index) {
const rotation = Math.PI * 2 / (pathCount * frameCount) * index;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const ox = position.x - center.x;
const oy = position.y - center.y;
const x = ox * cos - oy * sin + center.x;
const y = oy * cos + ox * sin + center.y;
return { x, y };
}
This means that the animation can just play without further calculations once the drawing is done. The loop happens after one fifth of a rotation around the center, since everything after that would just be unnecessary repetition.
function nextFrame() {
frame++;
if (frame >= frameCount) {
frame = 0;
}
reDraw();
}
function reDraw() {
context.clearRect(0, 0, canvas.width, canvas.height);
context.lineWidth = drawWidth;
for (let index = frame; index < paths.length; index += frameCount) {
context.stroke(paths[index]);
context.stroke(mirrorPaths[index]);
}
}
When saving to a PNG, the canvas is re-drawn with just the first frame before being downloaded.
function savePNG() {
context.fillRect(0, 0, canvas.width, canvas.height);
for (let index = 0; index < paths.length; index += frameCount) {
context.stroke(paths[index]);
context.stroke(mirrorPaths[index]);
}
const link = document.createElement("a");
link.href = canvas.toDataURL();
link.download = "mandala.png";
link.click();
}
To save GIFs I used gif.js. Every frame is drawn and added to the GIF object, before it is rendered and then downloaded. Since this can take a little bit of time, I decided to also disable the GIF button while rendering to both indicate that something is happening and prevent that you start more than one process at a time.
function saveGIF() {
gifButton.disabled = true;
const gif = new GIF({
width: canvas.width,
height: canvas.height
});
for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) {
context.fillRect(0, 0, canvas.width, canvas.height);
for (let pathIndex = frameIndex; pathIndex < paths.length; pathIndex += frameCount) {
context.stroke(paths[pathIndex]);
context.stroke(mirrorPaths[pathIndex]);
}
gif.addFrame(context, {copy:true, delay: frameTime});
}
gif.on("finished", blob => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "mandala.gif";
link.click();
URL.revokeObjectURL(url);
gifButton.disabled = false;
});
gif.render();
}
The ability to print was added later. I thought first about just creating a PNG and printing that, but decided the resolution would be a problem and went for creating an SVG instead.
<svg xmlns="http://www.w3.org/2000/svg" id="printImage"></svg>
Since I only wanted to print the first frame, I used a somewhat hacky way to add the positions to the SVG paths.
if (index % frameCount === 0) {
const printIndex = Math.floor(index / frameCount);
let d = printPaths[printIndex].attributes.d.value;
printPaths[printIndex].setAttribute("d", d + "L " + rotatedPosition.x + " " + rotatedPosition.y);
d = printPaths[printIndex + pathCount].attributes.d.value;
printPaths[printIndex + pathCount].setAttribute("d", d + "L " + flippedX + " " + rotatedPosition.y);
}
The print button just calls the standard print(), which means that printing the page normally produces the same result. This is done in the style definition by hiding everything except the otherwise hidden SVG.
svg {
display: none;
}
@media print {
.dark {
background-color: white;
}
.container {
display: none;
}
svg {
display: block;
}
}