'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 if (window.LabFX) { LabFX.glow.drawGlow(ctx, () => { this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); }, { color: '#9B5DE5', intensity: 4 }); } else { this._drawCurve(ctx, W, H, x => this._fTransformed(x), '#9B5DE5', 2.5); } 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(); })); } let _gtSoundTs = 0; 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 }); const now = performance.now(); if (window.LabFX && now - _gtSoundTs > 80) { _gtSoundTs = now; LabFX.sound.play('tick', { volume: 0.1 }); } } 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 }); if (window.LabFX) LabFX.sound.play('whoosh', { volume: 0.3 }); } 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 ── */