Files
Learn_System/frontend/js/labs/graphtransform.js
T
Maxim Dolgolyov ae31e4c4e8 refactor: distribute lab-init.js into 34 engine files
lab-init.js: 4098 -> 543 lines (infrastructure + THEORY only)

Each sim's _open*() + UI helpers moved to its engine file:
graph.js, projectile.js, collision.js, magnetic.js, triangle.js,
geometry.js, trigcircle.js, gas.js (molphys), coulomb.js, circuit.js,
reactions.js (chemistry), newton.js (dynamics), chemsandbox.js,
celldivision.js, photosynthesis.js, angrybirds.js, quadratic.js,
normaldist.js, graphtransform.js, pendulum.js, equilibrium.js,
thinlens.js, mirror.js, isoprocess.js, titration.js, refraction.js,
probability.js, bohratom.js, electrolysis.js, waves.js,
crystal.js, orbitals.js, stereo.js, hydrostatics.js

All 34 engine files syntax-checked OK.
2026-05-08 14:54:54 +03:00

407 lines
14 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;
}
getParams() { return { a: this.a, k: this.k, b: this.b, c: this.c }; }
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; });
}
}
/* ─── lab UI init ─────────────────────────────────── */
var gtSim = null;
function _openGraphTransform() {
document.getElementById('sim-topbar-title').textContent = 'Трансформации графиков';
_simShow('sim-graphtransform');
_registerSimState('graphtransform', () => gtSim?.getParams(), st => gtSim?.setParams(st));
if (_embedMode) _startStateEmit('graphtransform');
requestAnimationFrame(() => requestAnimationFrame(() => {
if (!gtSim) {
gtSim = new GraphTransformSim(document.getElementById('graphtransform-canvas'));
gtSim.onUpdate = _gtUpdateUI;
}
gtSim.fit();
gtSim.draw();
gtSim._emit();
}));
}
function gtParam(name, val) {
const v = parseFloat(val);
document.getElementById('gt-' + name + '-val').textContent = v % 1 === 0 ? v : v.toFixed(1);
if (gtSim) gtSim.setParams({ [name]: v });
}
function gtBase(name, btn) {
document.querySelectorAll('.gt-base-btn').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
if (gtSim) gtSim.setBase(name);
}
function gtEffect(a, k, b, c) {
document.getElementById('sl-gt-a').value = a; document.getElementById('gt-a-val').textContent = a;
document.getElementById('sl-gt-k').value = k; document.getElementById('gt-k-val').textContent = k;
document.getElementById('sl-gt-b').value = b; document.getElementById('gt-b-val').textContent = b;
document.getElementById('sl-gt-c').value = c; document.getElementById('gt-c-val').textContent = c;
if (gtSim) gtSim.setParams({ a, k, b, c });
}
function _gtUpdateUI(info) {
const v = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
v('gtbar-v1', info.base);
v('gtbar-v2', info.a);
v('gtbar-v3', info.k);
v('gtbar-v4', info.b);
v('gtbar-v5', info.c);
}
/* ── pendulum ── */