Files
Learn_System/frontend/js/labs/graphtransform.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

356 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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; });
}
}