431 lines
11 KiB
JavaScript
431 lines
11 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();
|
|
})
|
|
}
|
|
|
|
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();
|
|
}
|
|
} |