6afe928c0d
ФУНДАМЕНТ (4 новых файла): - _fx_core.js: LabFX namespace, glow.drawGlow, glow.pulse, haptic, shake - _fx_particles.js: пул 1500 объектов, 6 shapes (dot/spark/ring/smoke/splash/dust) - _fx_motion.js: tween + 12 easings + critically-damped spring - _fx_sound.js: 9 procedural synth-звуков (click/tick/whoosh/chime/fizz/spark/bounce/pour/drone), Web Audio API - Sound toggle в шапке lab.html с localStorage-persist UX МИКРО (CSS + JS): - Button states: hover scale+brightness, active scale-down, disabled grayscale - Slider polish: custom thumb с тенью, filled-track gradient, hover/active - Focus rings через :focus-visible - Tooltip system .tt-host data-tt= с 400ms hover, fade-in - Marching ants для selection - Loading skeleton с shimmer - Empty state .sim-empty-* паттерн - Toast: progress bar внизу, icons по типу - Cursor states utility classes - View Transitions API для smooth sim-switch, fallback на CSS fade PHASE 2 — визуальные эффекты для 33 симуляций: Physics motion: projectile (launch whoosh + landing splash/shake/haptic + target chime), pendulum (max-extension tick + bob glow), collision (bounce + sparks + shake), angrybirds (whoosh/bounce/fizz/chime + confetti), newton (rocket flame trail + scene transitions), forcesandbox (spring glow + impact sparks) Physics fields: emfield (field-lines glow + particle trail + lightning при high field + Gauss-drag haptic + rod motion sparks), circuit (energized-wire glow ∝ current + LED bloom + short-circuit shake/spark + heat shimmer smoke + place/erase/switch sounds), opticsbench (beam glow на всех режимах + caustics dust у фокуса + TIR/Brewster one-shot sounds) Thermo+waves: waves (mode-switch whoosh + Mach-cone particles + spectrum harmonic chime + waveform glow), hydrostatics (pour sound + splash при погружении + valve click), isoprocess (PV-trail dust), heatengine (drone цикла + hot/cold reservoir smoke/dust + phase-change ticks), radioactive (Geiger tick throttle + decay sparks + half-life chime + α/β/γ glow) Chemistry: chemsandbox (pour splash + fizz bubbles/dust/spark по типу реакции + shake при горении), equilibrium/electrolysis/reactions/titration/flask/ionexchange/redox (pour/fizz/spark/chime по событиям, частицы при ключевых моментах), stoichiometry (fizz bubbles + recipe-change click) Math+geom: geometry (tool-click + object-create tick + locus glow + challenge confetti via LabFX + haptic), triangle (vertex-drop tick + special-point glow), stereo (figure-change whoosh + cross-section chime, no particles — Three.js), trigcircle (drag-haptic + pitch∝angle tick + sin/cos glow), graph/graphtransform/quadratic (slider-tick throttled + curve glow + discriminant-cross chime), probability (bounce + finish-chime burst), normaldist (pulsing shade + bell glow) Bio+misc: celldivision (phase-change whoosh + prophase dust + anaphase poles spark + cytokinesis chime), photosynthesis (photon tick + ATP chime + glucose sparkle), bohratom (electron-jump chime ∝ levels + photon spark), orbitals/crystal (mode-change whoosh — Three.js, sound only), logic (gate-place + wire-connect + LED bloom + HIGH wire flowing dots + preset chime), gas/brownian/diffusion/states (preset whoosh, throttled tick по событиям) Все LabFX вызовы обёрнуты в if (window.LabFX) guard — graceful degradation если фундамент не загружен. 47 JS файлов прошли syntax check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
420 lines
15 KiB
JavaScript
420 lines
15 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;
|
||
}
|
||
|
||
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 ── */
|