be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
'use strict';
|
||
/* ══════════════════════════════════════════════════════════════
|
||
GraphTransformSim — graph transformations explorer
|
||
y = a·f(k·x + b) + c with sliders for a, k, b, c
|
||
Original f(x) shown faded, transformed shown bold.
|
||
══════════════════════════════════════════════════════════════ */
|
||
|
||
class GraphTransformSim {
|
||
constructor(canvas) {
|
||
this.canvas = canvas;
|
||
this.ctx = canvas.getContext('2d');
|
||
this.W = 0; this.H = 0;
|
||
|
||
/* base function */
|
||
this._baseFn = x => Math.sin(x);
|
||
this._baseLabel = 'sin(x)';
|
||
|
||
/* transform params */
|
||
this.a = 1;
|
||
this.k = 1;
|
||
this.b = 0;
|
||
this.c = 0;
|
||
|
||
/* view */
|
||
this.ox = 0;
|
||
this.oy = 0;
|
||
this.scl = 40;
|
||
|
||
this.hx = null;
|
||
this._drag = null;
|
||
this.onUpdate = null;
|
||
|
||
this._bind();
|
||
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement);
|
||
}
|
||
|
||
/* ── public ──────────────────────────────────────── */
|
||
|
||
fit() {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.canvas.offsetWidth || 600;
|
||
const h = this.canvas.offsetHeight || 400;
|
||
this.canvas.width = w * dpr;
|
||
this.canvas.height = h * dpr;
|
||
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
this.W = w; this.H = h;
|
||
}
|
||
|
||
setParams({ a, k, b, c } = {}) {
|
||
if (a !== undefined) this.a = +a;
|
||
if (k !== undefined) this.k = +k;
|
||
if (b !== undefined) this.b = +b;
|
||
if (c !== undefined) this.c = +c;
|
||
this.draw();
|
||
this._emit();
|
||
}
|
||
|
||
setBase(name) {
|
||
const BASES = {
|
||
'sin': { fn: x => Math.sin(x), label: 'sin(x)' },
|
||
'cos': { fn: x => Math.cos(x), label: 'cos(x)' },
|
||
'x^2': { fn: x => x * x, label: 'x²' },
|
||
'sqrt': { fn: x => x >= 0 ? Math.sqrt(x) : NaN, label: '√x' },
|
||
'|x|': { fn: x => Math.abs(x), label: '|x|' },
|
||
'1/x': { fn: x => x !== 0 ? 1 / x : NaN, label: '1/x' },
|
||
'x^3': { fn: x => x * x * x, label: 'x³' },
|
||
};
|
||
const b = BASES[name];
|
||
if (b) { this._baseFn = b.fn; this._baseLabel = b.label; this.draw(); this._emit(); }
|
||
}
|
||
|
||
resetView() { this.ox = 0; this.oy = 0; this.scl = 40; this.draw(); }
|
||
zoomIn() { this.scl = Math.min(800, this.scl * 1.3); this.draw(); }
|
||
zoomOut() { this.scl = Math.max(4, this.scl / 1.3); this.draw(); }
|
||
|
||
info() {
|
||
const { a, k, b, c } = this;
|
||
const parts = [];
|
||
if (a !== 1) parts.push(a === -1 ? '−' : a.toFixed(1) + '·');
|
||
parts.push(this._baseLabel.replace('x', this._innerStr()));
|
||
if (c > 0) parts.push(' + ' + c.toFixed(1));
|
||
if (c < 0) parts.push(' − ' + Math.abs(c).toFixed(1));
|
||
return {
|
||
base: this._baseLabel,
|
||
equation: 'y = ' + parts.join(''),
|
||
a: a.toFixed(1),
|
||
k: k.toFixed(1),
|
||
b: b.toFixed(1),
|
||
c: c.toFixed(1),
|
||
};
|
||
}
|
||
|
||
/* ── internals ──────────────────────────────────── */
|
||
|
||
_innerStr() {
|
||
const { k, b } = this;
|
||
let s = '';
|
||
if (k !== 1) s += (k === -1 ? '−' : k.toFixed(1) + '·');
|
||
s += 'x';
|
||
if (b > 0) s += ' + ' + b.toFixed(1);
|
||
if (b < 0) s += ' − ' + Math.abs(b).toFixed(1);
|
||
return s;
|
||
}
|
||
|
||
_fBase(x) { try { return this._baseFn(x); } catch { return NaN; } }
|
||
|
||
_fTransformed(x) {
|
||
const inner = this.k * x + this.b;
|
||
const base = this._fBase(inner);
|
||
return this.a * base + this.c;
|
||
}
|
||
|
||
_emit() { if (this.onUpdate) this.onUpdate(this.info()); }
|
||
|
||
/* ── coordinate transforms ────────────────────── */
|
||
|
||
_toPx(mx, my) {
|
||
return [
|
||
this.W / 2 + (mx - this.ox) * this.scl,
|
||
this.H / 2 - (my - this.oy) * this.scl,
|
||
];
|
||
}
|
||
|
||
_toMath(px, py) {
|
||
return [
|
||
(px - this.W / 2) / this.scl + this.ox,
|
||
-(py - this.H / 2) / this.scl + this.oy,
|
||
];
|
||
}
|
||
|
||
/* ── draw ────────────────────────────────────── */
|
||
|
||
draw() {
|
||
const ctx = this.ctx, W = this.W, H = this.H;
|
||
if (!W || !H) return;
|
||
|
||
ctx.fillStyle = '#0D0D1A';
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
this._drawGrid(ctx, W, H);
|
||
this._drawAxes(ctx, W, H);
|
||
this._drawCurve(ctx, W, H, x => this._fBase(x), 'rgba(255,255,255,0.18)', 2); // original faded
|
||
this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); // transformed bold
|
||
this._drawEquation(ctx, W, H);
|
||
if (this.hx !== null) this._drawHover(ctx, W, H);
|
||
}
|
||
|
||
_drawGrid(ctx, W, H) {
|
||
const step = this._niceStep();
|
||
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
|
||
const [, y0] = this._toMath(0, H), [, y1] = this._toMath(0, 0);
|
||
const gx = Math.floor(x0 / step) * step;
|
||
const gy = Math.floor(y0 / step) * step;
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.065)';
|
||
ctx.lineWidth = 1;
|
||
for (let x = gx; x <= x1 + step; x += step) {
|
||
const [px] = this._toPx(x, 0);
|
||
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||
}
|
||
for (let y = gy; y <= y1 + step; y += step) {
|
||
const [, py] = this._toPx(0, y);
|
||
ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke();
|
||
}
|
||
|
||
ctx.font = '11px Manrope, system-ui, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
const [axX, axY] = this._toPx(0, 0);
|
||
const lblY = Math.max(4, Math.min(H - 18, axY + 5));
|
||
const lblX = Math.max(28, Math.min(W - 6, axX - 5));
|
||
|
||
ctx.textAlign = 'center'; ctx.textBaseline = 'top';
|
||
for (let x = gx; x <= x1; x += step) {
|
||
if (Math.abs(x) < step * 0.01) continue;
|
||
const [px] = this._toPx(x, 0);
|
||
if (px < 18 || px > W - 18) continue;
|
||
ctx.fillText(this._fmtLabel(x, step), px, lblY);
|
||
}
|
||
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
|
||
for (let y = gy; y <= y1; y += step) {
|
||
if (Math.abs(y) < step * 0.01) continue;
|
||
const [, py] = this._toPx(0, y);
|
||
if (py < 12 || py > H - 12) continue;
|
||
ctx.fillText(this._fmtLabel(y, step), lblX, py);
|
||
}
|
||
}
|
||
|
||
_niceStep() {
|
||
const raw = this.W / this.scl / 8;
|
||
const p = Math.pow(10, Math.floor(Math.log10(raw)));
|
||
for (const m of [1, 2, 5, 10]) if (m * p >= raw) return m * p;
|
||
return p;
|
||
}
|
||
|
||
_fmtLabel(n, step) {
|
||
if (n === 0) return '0';
|
||
if (step >= 1 && Number.isInteger(n)) return String(n);
|
||
if (step < 0.001) return n.toExponential(1);
|
||
const dec = Math.max(0, -Math.floor(Math.log10(step)));
|
||
return n.toFixed(dec);
|
||
}
|
||
|
||
_drawAxes(ctx, W, H) {
|
||
const [ax, ay] = this._toPx(0, 0);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.4)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.moveTo(0, ay); ctx.lineTo(W - 10, ay); ctx.stroke();
|
||
ctx.beginPath(); ctx.moveTo(ax, H); ctx.lineTo(ax, 8); ctx.stroke();
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.4)';
|
||
const s = 5;
|
||
// x arrow
|
||
ctx.save(); ctx.translate(W - 8, ay); ctx.beginPath();
|
||
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore();
|
||
// y arrow
|
||
ctx.save(); ctx.translate(ax, 6); ctx.rotate(-Math.PI / 2); ctx.beginPath();
|
||
ctx.moveTo(0, 0); ctx.lineTo(-s * 1.6, -s * 0.6); ctx.lineTo(-s * 1.6, s * 0.6); ctx.closePath(); ctx.fill(); ctx.restore();
|
||
|
||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
||
ctx.font = 'bold 12px Manrope, sans-serif';
|
||
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
|
||
ctx.fillText('x', W - 10, ay - 13);
|
||
ctx.textBaseline = 'top'; ctx.textAlign = 'left';
|
||
ctx.fillText('y', ax + 7, 4);
|
||
}
|
||
|
||
_drawCurve(ctx, W, H, fn, color, lw) {
|
||
const steps = Math.min(W * 2, 2000);
|
||
const [x0] = this._toMath(0, 0), [x1] = this._toMath(W, 0);
|
||
const dx = (x1 - x0) / steps;
|
||
const maxJmp = (H / this.scl) * 2;
|
||
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = lw;
|
||
ctx.lineJoin = 'round';
|
||
ctx.beginPath();
|
||
|
||
let pen = false, pyPrev = null;
|
||
for (let i = 0; i <= steps; i++) {
|
||
const mx = x0 + i * dx;
|
||
const my = fn(mx);
|
||
if (!isFinite(my) || isNaN(my)) { pen = false; pyPrev = null; continue; }
|
||
if (pen && pyPrev !== null && Math.abs(my - pyPrev) > maxJmp) pen = false;
|
||
const [px, py] = this._toPx(mx, my);
|
||
pen ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
|
||
pen = true; pyPrev = my;
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
_drawEquation(ctx, W, H) {
|
||
const info = this.info();
|
||
ctx.font = 'bold 13px Manrope, sans-serif';
|
||
const text = info.equation;
|
||
const tw = ctx.measureText(text).width;
|
||
const x = W - tw - 24, y = 14;
|
||
|
||
ctx.fillStyle = 'rgba(22,22,38,0.85)';
|
||
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.fill();
|
||
ctx.strokeStyle = '#9B5DE5'; ctx.lineWidth = 1;
|
||
ctx.beginPath(); ctx.roundRect(x, y, tw + 16, 26, 8); ctx.stroke();
|
||
ctx.fillStyle = '#ddd';
|
||
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, x + 8, y + 13);
|
||
|
||
// base function label (faded)
|
||
const base = 'f(x) = ' + this._baseLabel;
|
||
ctx.font = '11px Manrope, sans-serif';
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.fillText(base, x + 8, y + 38);
|
||
}
|
||
|
||
_drawHover(ctx, W, H) {
|
||
const [px] = this._toPx(this.hx, 0);
|
||
const myOrig = this._fBase(this.hx);
|
||
const myTrans = this._fTransformed(this.hx);
|
||
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.15)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([5, 5]);
|
||
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// original point
|
||
if (isFinite(myOrig)) {
|
||
const [, py] = this._toPx(this.hx, myOrig);
|
||
if (py > -20 && py < H + 20) {
|
||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||
ctx.beginPath(); ctx.arc(px, py, 4, 0, Math.PI * 2); ctx.fill();
|
||
}
|
||
}
|
||
|
||
// transformed point
|
||
if (isFinite(myTrans)) {
|
||
const [, py2] = this._toPx(this.hx, myTrans);
|
||
if (py2 > -20 && py2 < H + 20) {
|
||
ctx.fillStyle = '#9B5DE5';
|
||
ctx.beginPath(); ctx.arc(px, py2, 5, 0, Math.PI * 2); ctx.fill();
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 1.5; ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── events ──────────────────────────────────── */
|
||
|
||
_bind() {
|
||
const cv = this.canvas;
|
||
|
||
cv.addEventListener('wheel', e => {
|
||
e.preventDefault();
|
||
const [mx, my] = this._toMath(e.offsetX, e.offsetY);
|
||
this.scl = Math.max(4, Math.min(800, this.scl * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
|
||
const [nx, ny] = this._toMath(e.offsetX, e.offsetY);
|
||
this.ox -= nx - mx; this.oy -= ny - my;
|
||
this.draw();
|
||
}, { passive: false });
|
||
|
||
cv.addEventListener('mousedown', e => {
|
||
this._drag = { x: e.clientX, y: e.clientY, ox: this.ox, oy: this.oy };
|
||
cv.style.cursor = 'grabbing';
|
||
});
|
||
window.addEventListener('mousemove', e => {
|
||
if (this._drag) {
|
||
this.ox = this._drag.ox - (e.clientX - this._drag.x) / this.scl;
|
||
this.oy = this._drag.oy + (e.clientY - this._drag.y) / this.scl;
|
||
this.draw();
|
||
} else {
|
||
const r = cv.getBoundingClientRect();
|
||
const lx = e.clientX - r.left, ly = e.clientY - r.top;
|
||
if (lx >= 0 && lx <= r.width && ly >= 0 && ly <= r.height) {
|
||
this.hx = this._toMath(lx, ly)[0];
|
||
this.draw();
|
||
}
|
||
}
|
||
});
|
||
window.addEventListener('mouseup', () => { this._drag = null; cv.style.cursor = 'crosshair'; });
|
||
cv.addEventListener('mouseleave', () => { this.hx = null; this.draw(); });
|
||
cv.style.cursor = 'crosshair';
|
||
|
||
let t0 = null;
|
||
cv.addEventListener('touchstart', e => {
|
||
if (e.touches.length === 1)
|
||
t0 = { x: e.touches[0].clientX, y: e.touches[0].clientY, ox: this.ox, oy: this.oy };
|
||
}, { passive: true });
|
||
cv.addEventListener('touchmove', e => {
|
||
e.preventDefault();
|
||
if (e.touches.length === 1 && t0) {
|
||
this.ox = t0.ox - (e.touches[0].clientX - t0.x) / this.scl;
|
||
this.oy = t0.oy + (e.touches[0].clientY - t0.y) / this.scl;
|
||
this.draw();
|
||
}
|
||
}, { passive: false });
|
||
cv.addEventListener('touchend', () => { t0 = null; });
|
||
}
|
||
}
|