class Drawing { constructor(divId) { this.div = document.getElementById(divId); this.div.classList.add('drawing'); this.div.classList.add('container'); this.buttonsDiv = document.createElement('div'); this.titleDiv = document.createElement('div'); this.titleDiv.classList.add('title'); this.captionDiv = document.createElement('div'); this.captionDiv.classList.add('caption'); this.canvas = document.createElement('canvas'); this.div.appendChild(this.titleDiv); this.div.appendChild(this.buttonsDiv); this.div.appendChild(this.canvas); this.div.appendChild(this.captionDiv); this.ctx = this.canvas.getContext('2d'); this.sequence = []; this.t = 0; this.rt = 0; this.dt = 0; this.points = {}; this.stopped = true; this.frame = [[-10, -10], [110, 110]]; this.frameMargin = 10; this.scale = 1.0; this.speed = 1.0; this.rendered = false; this.updateCanvasSize(); } addButtons() { if (!this.buttonsAdded) { this.startButton = document.createElement('button'); this.startButton.onclick = () => this.start(); this.startButton.innerHTML = "Start"; this.stopButton = document.createElement('button'); this.stopButton.onclick = () => this.stop(); this.stopButton.innerHTML = "Stop"; this.buttonsDiv.appendChild(this.startButton); this.buttonsDiv.appendChild(this.stopButton); this.buttonsAdded = true; this.updateButtonStates(); } } updateButtonStates() { if (this.stopped) { this.stopButton.disabled = true; this.startButton.disabled = false; } else { this.stopButton.disabled = false; this.startButton.disabled = true; } } setTitle(title) { this.titleDiv.innerHTML = title; } setCaption(caption) { this.captionDiv.innerHTML = caption; } start() { if (this.stopped) { this.elideInterval = true; this.stopped = false; } if (this.onStartFn) { this.onStartFn(); } this.addButtons(); this.updateButtonStates(); this.animate(); } stop() { this.stopped = true; this.updateButtonStates(); } render() { for (let action of this.sequence) { action(); } this.rendered = true; } animate() { this.ctx.reset(); this.render(); if (!this.stopped) { requestAnimationFrame((prevt) => { const rt = document.timeline.currentTime; const elapsed = rt - prevt; if (this.elideInterval) { this.dt = 0; this.elideInterval = false; } else { this.dt = (rt - this.rt + elapsed) * this.speed; } this.t += this.dt; this.rt = rt; this.animate(); }); } } pixel([x, y]) { return [ (x - this.frame[0][0]) * this.scale + this.frameMargin, this.canvas.height - (y - this.frame[0][1]) * this.scale - this.frameMargin ]; } onStart(fn) { this.onStartFn = fn; } setSpeed(speed) { this.speed = speed; } updateCanvasSize() { this.canvas.width = (this.frame[1][0] - this.frame[0][0]) * this.scale + 2 * this.frameMargin; this.canvas.height = (this.frame[1][1] - this.frame[0][1]) * this.scale + 2 * this.frameMargin; if (this.rendered) { this.render(); } } setFrame([x1, y1], [x2, y2]) { this.frame = [[x1, y1], [x2, y2]]; this.updateCanvasSize(); } setFrameMargin(frameMargin) { this.frameMargin = frameMargin; this.updateCanvasSize(); } setScale(zoom) { this.scale = zoom; this.updateCanvasSize(); } setStroke(style, width) { this.sequence.push(() => { this.ctx.strokeStyle = style; this.ctx.lineWidth = width; }); } setFill(style) { this.sequence.push(() => { this.ctx.fillStyle = style; }); } definePoint(name, fn) { this.points[name] = fn; } getPoint(p) { if (typeof p === 'string') { const fn = this.points[p]; if (!fn) { const e = new Error; e.message = `Point '${p}' is not defined`; throw e; } return fn(); } else { return p; } } line(p1, p2) { this.sequence.push(() => { this.ctx.beginPath(); this.ctx.moveTo(...this.pixel(this.getPoint(p1))); this.ctx.lineTo(...this.pixel(this.getPoint(p2))); this.ctx.stroke(); }); } arrow(p1, p2) { this.sequence.push(() => { this.ctx.beginPath(); this.ctx.moveTo(...this.pixel(this.getPoint(p1))); this.ctx.lineTo(...this.pixel(this.getPoint(p2))); this.ctx.stroke(); }); } polyline(...points) { this.sequence.push(() => { this.ctx.beginPath(); this.ctx.moveTo(...this.pixel(points[0])); for (let i = 1; i < points.length; i++) { this.ctx.lineTo(...this.pixel(points[i])); } this.ctx.stroke(); }); } oscillatingValue(x1, x2, period, initialPhase = 0) { const center = (x1 + x2) / 2; const amplitude = (x2 - x1) / 2; return center + amplitude * Math.sin(2*Math.PI*this.t/period + initialPhase); } oscillatingPoint([x1, y1], [x2, y2], period) { const x = this.oscillatingValue(x1, x2, period); const y = this.oscillatingValue(y1, y2, period); return [x, y]; } object(p, opts, fn) { let history = []; const maxAge = opts?.trace?.age ?? 0; const dashLength = 5; let distance = 0; this.sequence.push(() => { const [x, y] = this.getPoint(p); let ds = 0; if (history.length) { const dx = x - history[history.length - 1].p[0]; const dy = y - history[history.length - 1].p[1]; ds = Math.sqrt(dx**2 + dy**2); } distance += ds; history.push({t: this.t, p: [x, y], distance}); const oldest = history.findIndex(({t}) => this.t - t <= maxAge); if (oldest >= 0) { history = history.slice(oldest); } this.ctx.strokeStyle = 'gray'; this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.moveTo(...this.pixel(history[0].p)); for (let i = 1; i < history.length; i++) { if (Math.floor(history[i].distance / dashLength) % 2) { this.ctx.moveTo(...this.pixel(history[i].p)); } else { this.ctx.lineTo(...this.pixel(history[i].p)); } } this.ctx.stroke(); fn(x, y); }); return { reset: () => { history = []; distance = 0; } }; } square(p, opts) { const size = opts?.size ?? 10; return this.object(p, opts, (x, y) => { this.ctx.fillRect(...this.pixel([x - size / 2, y + size / 2]), size * this.scale, size * this.scale); }); } circle(p, opts) { const radius = opts?.radius ?? 5; return this.object(p, opts, (x, y) => { this.ctx.beginPath(); this.ctx.ellipse(...this.pixel([x, y]), radius * this.scale, radius * this.scale, 0, 0, 2*Math.PI); this.ctx.fill(); }); } func(opts, fn) { const origin = opts?.origin ?? [0, 0]; const domain = opts?.domain ?? [this.frame[0][0], this.frame[1][0]]; const step = opts?.step ?? 1; this.sequence.push(() => { this.ctx.beginPath(); this.ctx.moveTo(...this.pixel([domain[0], fn(domain[0])])); for (let x = domain[0] + step; x <= domain[1]; x += step) { this.ctx.lineTo(...this.pixel([x, fn(x)])); } this.ctx.lineTo(...this.pixel([domain[1], fn(domain[1])])); this.ctx.stroke(); }) } }