// // ============================================ // Recaman Visualization // ============================================ // https://twitter.com/benlorantfy // // Scroll inside the window to zoom. // Modify the configuration below to change // properties such as speed, colors, etc. // // The circle size increases by 1 each step. It tries to // go backwards if it can, but if the semi-circle will end // in a spot that is already occupied, it will go forward instead. // // The red color is when the circles go forwards, blue is when // a circle goes backwards. // // https://www.youtube.com/watch?v=FGC5TdIiT9U // https://oeis.org/A005132 // class RecamanSim { private config = { interval: 800, distanceBetweenNumbers: 20, backgroundColor: "black", zoom: 1, backwardsColor: '#9EBFFF', forwardsColor: '#FF433F', lineWidth: 5, }; private sequence = [0]; private biggestTerm = 0; private prevBiggestTerm = 0; private currentTermProgress = 0; constructor(canvas, config = {}) { this.config = { ...this.config, ...config }; this.canvas = canvas; this.ctx = canvas.getContext('2d'); } /* * Allows the user to resize the window and not * distort the animation */ private resize() { const canvas = this.canvas; this.canvasWidth = canvas.offsetWidth; this.canvasHeight = canvas.offsetHeight; canvas.setAttribute("width", this.canvasWidth); canvas.setAttribute("height", this.canvasHeight); } /* * Allows the user to zoom the simulation given a change in zoom */ public zoom(delta) { this.config.zoom += delta / 100; if (this.config.zoom < 0) { this.config.zoom = 0; } } private clear() { this.ctx.beginPath(); this.ctx.rect(0, 0, this.canvasWidth, this.canvasHeight); this.ctx.fillStyle = this.config.backgroundColor; this.ctx.fill(); this.ctx.closePath(); } /** * Determines the next term in the sequence. * * This is determined by taking the next term number * and trying to subtract it from the current term. * If this new term is already in the sequence, * it instead adds the term number to the current term. */ private determineNextTerm(sequence) { const length = sequence.length; const lastTerm = sequence[length - 1]; let newTerm = lastTerm - length; const visitedAlready = sequence.indexOf(newTerm) > -1; if (newTerm < 0 || visitedAlready) { newTerm = lastTerm + length; } return newTerm; } /** * The main simulation loop * * Adds a term to the sequence every interval. It's seperate * from the draw loop so we don't have to do any work inside the draw, * which might slow down the animation. It's also cleaner to have the drawing * and calculations seperate. */ private timeCounter = 0; private timeSinceSimStart = 0; private loop = (newTimeSinceSimStart = 0) => { const timeElapsedBetweenCalls = newTimeSinceSimStart - this.timeSinceSimStart; this.timeCounter += timeElapsedBetweenCalls; if (this.timeCounter > this.config.interval) { this.timeCounter = this.timeCounter - this.config.interval; const nextTerm = this.determineNextTerm(this.sequence); this.prevBiggestTerm = this.biggestTerm; if (nextTerm > this.biggestTerm) { this.biggestTerm = nextTerm; } this.sequence.push(nextTerm); } this.currentTermProgress = this.timeCounter / this.config.interval; this.timeSinceSimStart = newTimeSinceSimStart; requestAnimationFrame(this.loop); } /** * The draw loop * * This loops as fast as possible and draws the current state * of the simulation. The state is determined outside this loop, * making this kind of a pure function. It takes the current state * and draws it, performing no other side effects. */ private draw = () => { const canvasCenterX = this.canvasWidth / 2; const canvasCenterY = this.canvasHeight / 2; const targetSimWidth = this.biggestTerm * this.config.distanceBetweenNumbers; const prevTargetSimWidth = this.prevBiggestTerm * this.config.distanceBetweenNumbers; const deltaSimWidth = targetSimWidth - prevTargetSimWidth; const simWidth = prevTargetSimWidth + (deltaSimWidth * this.currentTermProgress); const simCenterX = canvasCenterX - simWidth / 2; const simCenterY = canvasCenterY; this.clear(); const zoom = this.config.zoom; this.ctx.scale(zoom, zoom); this.ctx.translate(canvasCenterX / zoom, canvasCenterY / zoom); this.ctx.translate(-(simWidth / 2), 0); if (this.config.debug) { this.ctx.beginPath(); this.ctx.arc(0,0,2,0,2*Math.PI); this.ctx.fillStyle = "red"; this.ctx.fill(); } this.sequence.forEach((term, n) => { const prevTerm = this.sequence[n - 1]; if (!prevTerm) return; const isLastTerm = n == this.sequence.length - 1; const shouldDrawUpsideDown = n % 2 === 0; const shouldDrawForward = term > prevTerm; const cx = (term + prevTerm) / 2 * this.config.distanceBetweenNumbers; const cy = 0; const r = n * this.config.distanceBetweenNumbers / 2; let startAngle = shouldDrawUpsideDown ? 0 : Math.PI; let endAngle = shouldDrawUpsideDown ? Math.PI : 0; if (shouldDrawForward) { this.ctx.strokeStyle = this.config.forwardsColor; } else { this.ctx.strokeStyle = this.config.backwardsColor; } if (isLastTerm) { if (shouldDrawUpsideDown) { if (shouldDrawForward) { startAngle = 0 + Math.PI * (1 - this.currentTermProgress); endAngle = Math.PI; } else { startAngle = 0; endAngle = Math.PI * this.currentTermProgress; } } else { if (shouldDrawForward) { startAngle = Math.PI; endAngle = 0 - Math.PI * (1 - this.currentTermProgress); } else { startAngle = Math.PI + Math.PI * (1 - this.currentTermProgress); endAngle = 0; } } } this.ctx.lineWidth = this.config.lineWidth; this.ctx.beginPath(); this.ctx.arc(cx, cy, r, startAngle, endAngle); this.ctx.stroke(); this.ctx.closePath(); }); this.ctx.resetTransform(); requestAnimationFrame(this.draw); } /** * Starts the simulation */ public start() { this.resize(); this.loop(); this.draw(); } } const sim = new RecamanSim(document.getElementsByTagName("canvas")[0], { // custom config can go here }); // Resize the simulation when the window is resized so the // canvas doesn't appear to stretch window.addEventListener("resize", () => { sim.resize(); }); window.addEventListener("wheel",function(e){ const delta = e.deltaY; sim.zoom(delta); }); sim.start();

!