class Drawing { constructor(div) { if (div instanceof HTMLElement) { this.div = div; } else { this.div = document.getElementById(div); } 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) { const h = document.createElement('h4'); h.innerHTML = title; this.titleDiv.appendChild(h); } 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(); }) } static fromText(node) { const div = document.createElement('div'); node.parentNode.parentNode.insertBefore(div, node.parentNode); node.style.display = 'none'; const d = new Drawing(div); const lines = node.innerText.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith("#")) continue; const [cmd, ...args] = line.split(' '); // console.log({cmd, args}); switch (cmd) { case 'title': { d.setTitle(args.join(' ')); } break; case 'caption': { d.setCaption(args.join(' ')); } break; case 'buttons': { d.addButtons(); } break; case 'frame': { const [x1, y1, x2, y2] = args.map(x => parseInt(x)); d.setFrame([x1, y1], [x2, y2]); } break; case 'axes': { const [x, y] = args.map(x => parseInt(x)); d.polyline([0, y], [0, 0], [x, 0]); } break; case 'stroke': { const style = args[0]; const width = parseInt(args[1]); d.setStroke(style, width); } break; case 'fill': { const style = args[0]; d.setFill(style); } break; case 'point': { const [name, ...rest] = args; let body = rest.join(' '); while (lines[i + 1].startsWith(' ')) { body += lines[i + 1]; i += 1; } d.definePoint(name, () => { return (function() { return eval(body); }).call(d); }); } break; case 'circle': case 'square': { const p = args[0]; const traceAge = args[1] ? parseInt(args[1]) : 0; d[cmd](p, {trace: {age: traceAge}}); } break; case 'start': { d.start(); } break; } } d.render(); } }