Spirograph experiment 2
What I wanted to do on the previous spirograph experiment, was to re-sample the canvas, so that the picture would naturally loose quality with time. It also works different in terms of performance. In theory, it allows longer lines because it does not need to calculate the rotation of each point in history.
Many interesting things can be done from this canvas re-sampling technique. In this case I am rotating the image, but it's also possible to zoom, translate, alter colors, etc. Also it's interesting to see what happens with smoothing disabled.
// @ts-check
const canvas = document.createElement('canvas');
const container = document.getElementById('code-target')
const speedSelector = document.createElement('input');
speedSelector.type = 'range';
speedSelector.min = '0.000001';
speedSelector.max = '0.01';
speedSelector.step = '0.000001';
speedSelector.setAttribute("value", '0.009');
if (!container) {
throw new Error('Container not found');
}
container.appendChild(speedSelector);
container.appendChild(canvas);
// create backing canvas
var bakeCanvas = document.createElement('canvas');
var bakeContext = bakeCanvas.getContext('2d');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Canvas context not found');
}
if (!bakeContext) {
throw new Error('Bake canvas context not found');
}
/**
* @typedef {Object} DrawScope
* @property {CanvasRenderingContext2D} context - canvas context
* @property {CanvasRenderingContext2D} bakeContext - canvas context
* @property {number} width -
* @property {number} height -
* @property {Vector2} middle -
*/
/**
* @type {DrawScope}
*/
const drawScope = {
context,
bakeContext,
width: 0,
height: 0,
middle: [0, 0],
}
const view = {
range: [2, 2],
offset: [1, 1],
}
/**
* @typedef { [number, number]} Vector2
*/
/**
* @param {Vector2} coords
* @param {number} angle
* @returns {Vector2}
*/
const rotateCoords = (coords, angle) => {
const [x, y] = coords;
const sin = Math.sin(angle);
const cos = Math.cos(angle);
return [
x * cos - y * sin,
x * sin + y * cos
];
}
/**
* @param {Vector2} coords
* @param {Vector2} offset
* @returns {Vector2}
*/
const translateCoords = (coords, offset) => {
const [x, y] = coords;
const [dx, dy] = offset;
return [x + dx, y + dy];
}
/**
* @param {Vector2} coords
* @param {...Function} callbacks
* @returns {Vector2}
*/
const processCoords = (coords, ...callbacks) => {
let res = coords;
callbacks.forEach(callback => {
res = callback(res);
});
return res;
}
let lastTime = new Date().getTime();
/** @type {Vector2 | null} */
let mouseLineFrom = null;
/**
* @param {number} time
* @param {DrawScope} drawScope
*/
let draw = (time, {
context, bakeContext, width, height, middle
}) => {
const deltaTime = time - lastTime;
lastTime = time;
context.strokeStyle = 'white';
context.fillStyle = 'white';
context.lineCap = 'round';
// context.imageSmoothingEnabled = false;
// bakeContext.imageSmoothingEnabled = false;
context.imageSmoothingQuality = 'high';
bakeContext.imageSmoothingQuality = 'high';
context.lineWidth = 4;
const speedVal = parseFloat(speedSelector.value);
const rotTimesDeltaTime = speedVal * deltaTime;
// console.log(rotTimesDeltaTime);
const rotationCenter = middle;
/** @type {Vector2} */
const negativeRotationCenter = [
-rotationCenter[0],
-rotationCenter[1]
];
context.clearRect(0, 0, width, height);
context.drawImage(bakeCanvas, 0, 0);
const minSize = Math.min(drawScope.width, drawScope.height);
if (mouse.down) {
if (mouseLineFrom) {
context.beginPath();
const r = minSize / 40;
const [x, y] = mouse.pos;
const rotated = processCoords([...mouseLineFrom], (pos) => {
return translateCoords(pos, negativeRotationCenter);
}, (pos) => {
return rotateCoords(pos, rotTimesDeltaTime);
}, (pos) => {
return translateCoords(pos, rotationCenter);
});
context.moveTo(...rotated);
context.lineTo(x, y);
context.stroke();
}
mouseLineFrom = [...mouse.pos];
}
// bakeContext.scale(0.99, 0.99);
// bakeContext.translate(-x, -y);}
// bakeContext.clearRect(0, 0, width, height);
bakeContext.translate(...rotationCenter);
bakeContext.rotate(rotTimesDeltaTime);
bakeContext.translate(...negativeRotationCenter);
bakeContext.drawImage(canvas, 0, 0);
if (mouse.down) {
bakeContext.fillStyle = `rgba(0,0,0,${rotTimesDeltaTime / 10})`;
bakeContext.fillRect(0, 0, width, height);
}
bakeContext.resetTransform();
}
const windowResizedListener = () => {
const containerRect = container.getBoundingClientRect();
if(containerRect.height < 500){
containerRect.height = 500;
}
canvas.width = containerRect.width;
canvas.height = containerRect.height;
drawScope.width = canvas.width;
drawScope.height = canvas.height;
drawScope.middle = [drawScope.width / 2, drawScope.height / 2];
bakeCanvas.width = canvas.width;
bakeCanvas.height = canvas.height;
}
window.addEventListener('resize', windowResizedListener);
const frame = () => {
const time = new Date().getTime();
draw(time, drawScope);
requestAnimationFrame(frame);
}
/**
* @typedef {Object} Mouse
* @property {Vector2} pos
* @property {boolean} down
*/
/** @type {Mouse} */
const mouse = {
pos: [0, 0],
down: false,
};
canvas.addEventListener('mousemove', (event) => {
mouse.pos = [event.offsetX, event.offsetY];
});
canvas.addEventListener('mousedown', () => {
mouse.down = true;
});
window.addEventListener('mouseup', () => {
mouse.down = false;
mouseLineFrom = null;
});
window.addEventListener('load', () => {
windowResizedListener();
});
windowResizedListener();
frame();