441 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
			
		
		
	
	
			441 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
| 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.values = {};
 | |
|     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;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   defineValue(name, fn) {
 | |
|     this.values[name] = fn;
 | |
|   }
 | |
| 
 | |
|   getValue(name) {
 | |
|     const fn = this.values[name];
 | |
|     if (!fn) {
 | |
|       const e = new Error;
 | |
|       e.message = `Value '${name}' is not defined`;
 | |
|       throw e;
 | |
|     }
 | |
|     return fn();
 | |
|   }
 | |
|      
 | |
|   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) {
 | |
|     const headLength = 10;
 | |
|     const headWidth = 10;
 | |
|     this.sequence.push(() => {
 | |
|       const start = new Vector(this.getPoint(p1));
 | |
|       const end = new Vector(this.getPoint(p2));
 | |
|       const r = end.sub(start);
 | |
|       const b = end.sub(r.mult(headLength / r.length));
 | |
|       const c1 = b.add(r.rot90().mult(0.5 * headWidth / r.length));
 | |
|       const c2 = b.sub(r.rot90().mult(0.5 * headWidth / r.length));
 | |
| 
 | |
|       // Arrow shaft
 | |
|       this.ctx.beginPath();
 | |
|       this.ctx.moveTo(...this.pixel(start.array));
 | |
|       this.ctx.lineTo(...this.pixel(end.array));
 | |
|       this.ctx.stroke();
 | |
| 
 | |
|       // Arrow head
 | |
|       this.ctx.beginPath();
 | |
|       this.ctx.moveTo(...this.pixel(c1.array));
 | |
|       this.ctx.lineTo(...this.pixel(end.array));
 | |
|       this.ctx.lineTo(...this.pixel(c2.array));
 | |
|       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 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();
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   text(p, text, opts) {
 | |
|     this.sequence.push(() => {
 | |
|       const [x, y] = this.pixel(this.getPoint(p));
 | |
|       this.ctx.font = "12pt sans-serif";
 | |
|       this.ctx.textAlign = opts?.align ?? 'left';
 | |
|       this.ctx.textBaseline = opts?.baseline ?? 'bottom';
 | |
|       this.ctx.fillText(text, x, y);
 | |
|     });
 | |
|   }
 | |
|   
 | |
|   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 'start': {
 | |
|           d.start();
 | |
|         } break;
 | |
|         case 'title': {
 | |
|           d.setTitle(args.join(' '));
 | |
|         } break;
 | |
|         case 'caption': {
 | |
|           d.setCaption(args.join(' '));
 | |
|         } break;
 | |
|         case 'buttons': {
 | |
|           d.addButtons();
 | |
|         } break;
 | |
|         case 'scale': {
 | |
|           const [scale] = args;
 | |
|           d.setScale(scale);
 | |
|         } break;
 | |
|         case 'frame': {
 | |
|           const [x1, y1, x2, y2] = args.map(x => eval(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 (i < lines.length - 1 && lines[i + 1].startsWith(' ')) {
 | |
|             body += lines[i + 1];
 | |
|             i += 1;
 | |
|           }
 | |
|           d.definePoint(name, () => {
 | |
|             return (function(_) {
 | |
|               return eval(body);
 | |
|             })(d);
 | |
|           });
 | |
|         } break;
 | |
|         case 'value': {
 | |
|           const [name, ...rest] = args;
 | |
|           let body = rest.join(' ');
 | |
|           while (i < lines.length - 1 && lines[i + 1].startsWith(' ')) {
 | |
|             body += lines[i + 1];
 | |
|             i += 1;
 | |
|           }
 | |
|           d.defineValue(name, () => {
 | |
|             return (function(_) {
 | |
|               return eval(body);
 | |
|             })(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 'func': {
 | |
|           let body = args.join(' ');
 | |
|           while (i < lines.length - 1 && lines[i + 1].startsWith(' ')) {
 | |
|             body += lines[i + 1];
 | |
|             i += 1;
 | |
|           }
 | |
|           d.func({step: 0.1}, (x) => {
 | |
|             return (function(_) {
 | |
|               return eval(body);
 | |
|             })(d);
 | |
|           });
 | |
|         } break;
 | |
|         case 'eval': {
 | |
|           let body = args.join(' ');
 | |
|           while (i < lines.length - 1 && lines[i + 1].startsWith(' ')) {
 | |
|             body += lines[i + 1];
 | |
|             i += 1;
 | |
|           }
 | |
|           (function(_) {
 | |
|             eval(body);
 | |
|           })(d);
 | |
|         } break;
 | |
|       }
 | |
|     }
 | |
|     d.render();
 | |
|   }
 | |
| } |