From 8f30a8cef6ae7053cd4f0e6214640c0ba5d75e4e Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 23 May 2026 12:48:14 +0300 Subject: [PATCH] =?UTF-8?q?feat(labs):=20wave=202=20=E2=80=94=20depth=20fe?= =?UTF-8?q?atures=20across=206=20sims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Электрические цепи (circuit): - Индуктивность L как новый компонент (1–1000 мГн, шорт в DC, jωL в AC) - RLC preset для демонстрации резонанса - Осциллограф: U(t)/I(t) для выбранного компонента, 100 sample, dual-axis - Heatmap мощности: радиальный градиент halo от blue→red пропорционально P=UI Стереометрия 3D (stereo): - Сечение через 3 произвольные точки: pick на гранях/рёбрах/вершинах - Плоскость + полигон пересечения с авто-определением типа (3–6-угольник) и площадью - Step-by-step режим: визуализация P1→линия→P2→линия→P3→плоскость→сечение - Поддержка всех solids (включая cylinder/cone через sampling fallback) Планиметрия (geometry): - Задачник framework: CHALLENGES[] с setup/check функциями - 5 стартовых задач: серединный перпендикуляр, биссектриса, описанная окружность, ГМТ, касательная - Авто-checker: толерантности ±0.5° для углов, ±1–5% для расстояний - UI: collapsible панель с статус-иконками, конфетти + «Молодец!» на success Электромагнитные поля (emfield): - Preset «Тороид»: 16+16 проводов в концентрических кольцах - Поверхность Гаусса: draggable круг, считает Φ = q_enc/ε₀, подсвечивает охваченные заряды - Motional EMF: draggable rod, arrow-keys управление, считает ε = ∫(v×B)·dl Химическая песочница (chemsandbox): - Live-overlay с уравнением реакции: молекулярное / полное ионное / сокращённое ионное - Coverage: 49/49 молекулярных, 34/49 ионных, 36/49 сокращённых - Auto-hide через 5 сек, fade-in animation, цветовая кодировка типов Волны и звук (waves): - Doppler: source+observer drag, expanding wavefronts, f_obs формула, Mach cone при v>c - Beats: f1+f2, sum waveform с envelope, индикация f_beat=|f1-f2| - Spectrum (DFT): N=256 samples pure JS, bar-chart с пиками и labels, «Добавить гармонику» Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/css/lab.css | 222 ++++++++++++++ frontend/js/labs/chemsandbox.js | 55 +++- frontend/js/labs/circuit.js | 241 +++++++++++++++- frontend/js/labs/emfield.js | 438 +++++++++++++++++++++++++++- frontend/js/labs/geometry.js | 461 +++++++++++++++++++++++++++++ frontend/js/labs/stereo.js | 326 ++++++++++++++++++++- frontend/js/labs/waves.js | 493 ++++++++++++++++++++++++++++++-- frontend/lab.html | 167 +++++++++++ 8 files changed, 2367 insertions(+), 36 deletions(-) diff --git a/frontend/css/lab.css b/frontend/css/lab.css index a595f5e..81072a6 100644 --- a/frontend/css/lab.css +++ b/frontend/css/lab.css @@ -798,6 +798,92 @@ display: flex; align-items: center; gap: 6px; } .geo-toggle-label svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; opacity: .7; } + + /* ════════════════════════════════ + CHEMSANDBOX — EQUATION OVERLAY + ════════════════════════════════ */ + .chemsand-eq-overlay { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + min-width: 340px; + max-width: min(660px, calc(100% - 28px)); + background: rgba(6, 6, 18, 0.82); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1.5px solid rgba(255, 255, 255, 0.10); + border-radius: 16px; + padding: 12px 18px 13px; + display: flex; + flex-direction: column; + gap: 7px; + z-index: 20; + pointer-events: none; + opacity: 0; + transition: opacity 0.28s ease; + } + .chemsand-eq-overlay.visible { + opacity: 1; + } + + .chemsand-eq-type { + font-family: 'Manrope', sans-serif; + font-size: 0.65rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .09em; + border: 1px solid rgba(255,255,255,.18); + border-radius: 6px; + padding: 1px 8px; + align-self: flex-start; + margin-bottom: 2px; + } + + .chemsand-eq-row { + display: flex; + flex-direction: column; + gap: 2px; + } + + .chemsand-eq-label { + font-family: 'Manrope', sans-serif; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .07em; + color: rgba(255,255,255,.35); + } + + .chemsand-eq-text { + font-family: 'JetBrains Mono', 'Fira Mono', monospace; + font-size: 0.9rem; + font-weight: 600; + line-height: 1.35; + color: rgba(255, 255, 255, 0.95); + word-break: break-word; + } + + /* full ionic — slightly dimmer, smaller */ + .chemsand-eq-text--full { + font-size: 0.78rem; + font-weight: 500; + color: rgba(155, 200, 255, 0.75); + } + + /* net ionic — highlighted */ + .chemsand-eq-text--net { + font-size: 0.82rem; + font-weight: 700; + color: var(--cyan, #06D6E0); + text-shadow: 0 0 12px rgba(6,214,224,.35); + } + + /* separator between rows */ + .chemsand-eq-row + .chemsand-eq-row { + border-top: 1px solid rgba(255,255,255,.06); + padding-top: 6px; + } .geo-toggle { width: 28px; height: 16px; border-radius: 8px; background: rgba(255,255,255,.1); border: 1.5px solid var(--border-h); @@ -865,3 +951,139 @@ .geo-del-btn-hard:hover { background: rgba(248,113,113,.18); } .geo-del-btn-cancel{ border-color: rgba(255,255,255,.15); color: rgba(255,255,255,.5); background: transparent; } .geo-del-btn-cancel:hover{ background: rgba(255,255,255,.06); } + + /* ── Задачник (challenge panel) ── */ + .geo-challenge-toggle { + display: flex; align-items: center; gap: 6px; + padding: 7px 9px; border-radius: 10px; + border: 1.5px solid rgba(74,222,128,.35); + background: rgba(74,222,128,.05); color: #4ADE80; + font-family: 'Manrope', sans-serif; font-size: 0.73rem; font-weight: 700; + cursor: pointer; transition: all .14s; width: 100%; margin-top: 6px; + } + .geo-challenge-toggle:hover { background: rgba(74,222,128,.12); border-color: rgba(74,222,128,.6); } + .geo-challenge-toggle svg { width: 13px; height: 13px; stroke: currentColor; flex-shrink: 0; } + .geo-challenge-toggle .chall-count { + margin-left: auto; font-size: 0.68rem; opacity: .75; + } + + .geo-challenge-panel { + position: absolute; top: 0; right: 0; bottom: 0; + width: 300px; background: rgba(12,8,24,.97); + border-left: 1.5px solid rgba(74,222,128,.2); + display: flex; flex-direction: column; + z-index: 15; transform: translateX(100%); + transition: transform .22s cubic-bezier(.4,0,.2,1); + overflow: hidden; + } + .geo-challenge-panel.open { transform: translateX(0); } + + .geo-chall-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 14px 10px; + border-bottom: 1px solid rgba(255,255,255,.08); + flex-shrink: 0; + } + .geo-chall-header-title { + font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800; + color: #4ADE80; letter-spacing: .05em; + } + .geo-chall-close { + width: 26px; height: 26px; border-radius: 6px; + border: 1px solid rgba(255,255,255,.12); background: transparent; + color: rgba(255,255,255,.5); cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: background .13s; + } + .geo-chall-close:hover { background: rgba(255,255,255,.06); color: #fff; } + .geo-chall-close svg { width: 13px; height: 13px; stroke: currentColor; } + + .geo-chall-list { + flex: 1; overflow-y: auto; padding: 10px 12px; + display: flex; flex-direction: column; gap: 8px; + } + + .geo-chall-item { + border-radius: 12px; border: 1.5px solid var(--border); + background: rgba(255,255,255,.02); overflow: hidden; + transition: border-color .15s; + } + .geo-chall-item.chall-current { border-color: rgba(74,222,128,.4); } + .geo-chall-item.chall-done { border-color: rgba(74,222,128,.25); } + .geo-chall-item.chall-locked { opacity: .45; pointer-events: none; } + + .geo-chall-head { + display: flex; align-items: center; gap: 8px; + padding: 9px 11px; cursor: pointer; + } + .geo-chall-status { + width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0; + border: 1.5px solid rgba(255,255,255,.15); + display: flex; align-items: center; justify-content: center; + font-size: 0.65rem; font-weight: 800; + color: rgba(255,255,255,.3); background: rgba(255,255,255,.04); + } + .chall-current .geo-chall-status { + border-color: #4ADE80; color: #4ADE80; background: rgba(74,222,128,.1); + } + .chall-done .geo-chall-status { + border-color: #4ADE80; background: rgba(74,222,128,.18); + } + .geo-chall-name { + font-size: 0.73rem; font-weight: 700; color: var(--text-2); + flex: 1; min-width: 0; + } + .chall-current .geo-chall-name { color: var(--text); } + .chall-done .geo-chall-name { color: rgba(74,222,128,.8); } + + .geo-chall-body { + padding: 0 11px 10px; display: none; + } + .geo-chall-item.chall-current .geo-chall-body, + .geo-chall-item.geo-chall-expanded .geo-chall-body { display: block; } + + .geo-chall-desc { + font-size: 0.72rem; line-height: 1.55; color: rgba(255,255,255,.55); + margin-bottom: 9px; + } + .geo-chall-actions { display: flex; gap: 6px; } + .geo-chall-btn { + flex: 1; padding: 6px 0; border-radius: 8px; border: 1.5px solid; + font-family: 'Manrope', sans-serif; font-size: 0.7rem; font-weight: 700; + cursor: pointer; transition: all .13s; text-align: center; + } + .geo-chall-btn-check { + border-color: rgba(74,222,128,.4); color: #4ADE80; background: rgba(74,222,128,.07); + } + .geo-chall-btn-check:hover { background: rgba(74,222,128,.18); } + .geo-chall-btn-reset { + border-color: rgba(255,255,255,.12); color: rgba(255,255,255,.45); background: transparent; + flex: 0 0 auto; padding: 6px 9px; + } + .geo-chall-btn-reset:hover { background: rgba(255,255,255,.06); color: rgba(255,255,255,.7); } + + .geo-chall-hint { + font-size: 0.68rem; color: #FBBF24; margin-top: 7px; line-height: 1.45; + display: none; + } + .geo-chall-hint.visible { display: block; } + + .geo-chall-feedback { + font-size: 0.7rem; font-weight: 700; margin-top: 6px; min-height: 14px; + transition: color .2s; + } + .geo-chall-feedback.ok { color: #4ADE80; } + .geo-chall-feedback.err { color: #f87171; } + + @keyframes chall-success-fade { + 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 60% { opacity: 1; transform: translate(-50%, -60%) scale(1.08); } + 100% { opacity: 0; transform: translate(-50%, -70%) scale(1.12); } + } + .geo-chall-success-label { + position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); + font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800; + color: #4ADE80; pointer-events: none; z-index: 25; + text-shadow: 0 0 30px rgba(74,222,128,.7), 0 2px 6px rgba(0,0,0,.8); + animation: chall-success-fade 2.2s ease-out forwards; + } diff --git a/frontend/js/labs/chemsandbox.js b/frontend/js/labs/chemsandbox.js index 98a373f..e043ec0 100644 --- a/frontend/js/labs/chemsandbox.js +++ b/frontend/js/labs/chemsandbox.js @@ -500,7 +500,8 @@ class ChemSandboxSim { type: r ? r.type : null, equation: r ? r.eq : null, products: r && !r.fx.none ? this._productsStr(r) : null, - ionNet: r ? r.ionNet || null : null, + ionFull: r ? r.ionFull || null : null, + ionNet: r ? r.ionNet || null : null, why: r ? r.why || null : null, }; } @@ -1834,6 +1835,58 @@ class ChemSandboxSim { _lastReportedEquation = info.equation; if (window.LS?.reportLabActivity) LS.reportLabActivity(1).catch(() => {}); } + // ── HTML overlay: 3-form equation display ── + _chemSandShowEqOverlay(info); + } + + /* equation overlay timer handle */ + let _csEqOverlayTimer = null; + + function _chemSandShowEqOverlay(info) { + const overlay = document.getElementById('chemsand-eq-overlay'); + if (!overlay) return; + + // clear any existing hide timer + if (_csEqOverlayTimer) { clearTimeout(_csEqOverlayTimer); _csEqOverlayTimer = null; } + + if (!info.reaction || !info.equation) { + overlay.classList.remove('visible'); + return; + } + + /* helpers: strip SVG arrow markup → plain text "=" */ + function _stripSvg(s) { + if (!s) return ''; + return s.replace(/]*class="ic"[^>]*>[\s\S]*?<\/svg>/g, '='); + } + + const molLine = document.getElementById('chemsand-eq-mol'); + const fullLine = document.getElementById('chemsand-eq-full'); + const netLine = document.getElementById('chemsand-eq-net'); + const typeBadge = document.getElementById('chemsand-eq-type'); + + molLine.innerHTML = _stripSvg(info.equation); + fullLine.innerHTML = info.ionFull ? _stripSvg(info.ionFull) : 'ионное уравнение недоступно'; + netLine.innerHTML = info.ionNet ? _stripSvg(info.ionNet) : 'сокращённое ионное недоступно'; + + const tpColor = info.type === 'Нет реакции' ? '#EF476F' + : info.type === 'Индикатор' ? '#9B59B6' + : info.type === 'Нейтрализация'? '#FFD166' + : info.type === 'Замещение' ? '#06D6E0' + : info.type === 'Обмен' ? '#7BF5A4' + : info.type === 'Акт. металл' ? '#EF476F' + : 'rgba(255,255,255,.5)'; + typeBadge.textContent = info.type || ''; + typeBadge.style.color = tpColor; + typeBadge.style.borderColor = tpColor + '55'; + + overlay.classList.add('visible'); + + /* auto-hide after 5 s */ + _csEqOverlayTimer = setTimeout(() => { + overlay.classList.remove('visible'); + _csEqOverlayTimer = null; + }, 5000); } /* ── Cell Division ── */ diff --git a/frontend/js/labs/circuit.js b/frontend/js/labs/circuit.js index dfbc359..eccfc1e 100644 --- a/frontend/js/labs/circuit.js +++ b/frontend/js/labs/circuit.js @@ -29,9 +29,14 @@ class CircuitSim { this.R_value = 10; this.U_value = 9; this.C_value = 100; // µF (display label) + this.L_value = 10; // mH for inductor this.acFreq = 2; // Hz for AC source this.ledColor = '#7BF5A4'; + // Features + this._heatmapOn = false; // power heatmap overlay toggle + this._oscPanel = null; // oscilloscope canvas element (injected from outside) + // Interaction this._drawing = null; // {x1, y1} while dragging new component this._ghostEnd = null; // {x2, y2} cursor snap @@ -94,7 +99,7 @@ class CircuitSim { this._history.push(JSON.stringify(this.components.map(c => ({ id: c.id, type: c.type, x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, - value: c.value, open: c.open, + value: c.value, L_value: c.L_value, open: c.open, ledColor: c.ledColor, acFreq: c.acFreq })))); if (this._history.length > 20) this._history.shift(); @@ -137,6 +142,7 @@ class CircuitSim { case 'resistor': return Math.max(0.001, c.value || 10); case 'lamp': return 20; case 'capacitor': return 1e7; // open circuit in DC + case 'inductor': return 0.001; // short circuit in DC; AC handled in _buildMatrix case 'switch': return c.open ? 1e9 : 0.001; case 'diode': case 'led': return this._diodeR.get(c.id) ?? 1e9; @@ -197,7 +203,19 @@ class CircuitSim { if (comp.type === 'battery' || comp.type === 'ac') continue; const n1 = nodeOf(`${comp.x1},${comp.y1}`); const n2 = nodeOf(`${comp.x2},${comp.y2}`); - const R = this._compR(comp); + let R = this._compR(comp); + // AC overrides: inductor → |jωL|, capacitor → 1/|jωC| + if (this._hasAC && comp.type === 'inductor') { + const freq = this.acFreq || 2; + const L_H = (comp.L_value || this.L_value || 10) * 1e-3; // mH → H + const Xl = 2 * Math.PI * freq * L_H; + R = Math.max(0.001, Xl); + } else if (this._hasAC && comp.type === 'capacitor') { + const freq = this.acFreq || 2; + const C_F = (comp.value || this.C_value || 100) * 1e-6; // µF → F + const Xc = 1 / (2 * Math.PI * freq * C_F); + R = Math.max(0.001, Xc); + } if (R >= 1e7) continue; const g = 1 / R; stamp(n1 - 1, n1 - 1, g); @@ -279,6 +297,8 @@ class CircuitSim { } _solve() { + // Detect AC presence first so _buildMatrix uses correct impedances + this._hasAC = this.components.some(c => c.type === 'ac'); // Init diode R for (const c of this.components) { if ((c.type === 'diode' || c.type === 'led') && !this._diodeR.has(c.id)) { @@ -299,7 +319,6 @@ class CircuitSim { } if (!changed) break; } - this._hasAC = this.components.some(c => c.type === 'ac'); if (this.onUpdate) this.onUpdate(this.info()); } @@ -319,6 +338,9 @@ class CircuitSim { c._t = ((c._t || 0) + speed * dt * 60) % 1; } this.draw(); + if (this._oscPanel && this._oscPanel.offsetParent !== null) { + this.drawOscilloscope(this._oscPanel); + } this._raf = requestAnimationFrame(ts => this._tick(ts)); } @@ -674,6 +696,40 @@ class CircuitSim { ctx.textBaseline='alphabetic'; } + _drawInductor(ctx, c, p1, p2, mx, my, hasI) { + const dx=p2.x-p1.x, dy=p2.y-p1.y; + const len=Math.hypot(dx,dy); + const ux=dx/len, uy=dy/len; + const nx=-uy, ny=ux; + const hw=18; // half-width of coil region + const sP1={x:mx-ux*hw, y:my-uy*hw}; + const sP2={x:mx+ux*hw, y:my+uy*hw}; + this._drawWireLine(ctx, p1, sP1, c._v1, c._v1, 3, hasI); + this._drawWireLine(ctx, sP2, p2, c._v2, c._v2, 3, hasI); + + // 3 half-circle loops + ctx.save(); + ctx.translate(mx, my); + ctx.rotate(Math.atan2(dy, dx)); + const loopR=6, loopN=3; + const totalW=loopN*loopR*2; + ctx.strokeStyle=this._voltColor((c._v1+c._v2)/2, 0.9); + ctx.lineWidth=2; ctx.lineCap='round'; + for (let i=0;i + c.type === 'resistor' || c.type === 'lamp' || c.type === 'led' || + c.type === 'diode' || c.type === 'inductor' + ); + if (!dissipators.length) return; + const powers = dissipators.map(c => { + const R = this._compR(c); + return Math.abs((c._I ?? 0) ** 2 * Math.min(R, 1e6)); + }); + const Pmax = Math.max(...powers, 1e-9); + + for (let i = 0; i < dissipators.length; i++) { + const c = dissipators[i]; + const p1 = this._nodePixel(c.x1, c.y1), p2 = this._nodePixel(c.x2, c.y2); + const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2; + const t = powers[i] / Pmax; // 0..1 + if (t < 0.01) continue; + // Color: cool blue (t=0) → orange (t=0.5) → red (t=1) + const r = Math.round(30 + t * 225); + const g = Math.round(100 - t * 100); + const b = Math.round(220 - t * 220); + const radius = 28 + t * 24; + const grd = ctx.createRadialGradient(mx, my, 0, mx, my, radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${(0.18 + t * 0.32).toFixed(2)})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.beginPath(); + ctx.arc(mx, my, radius, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + } + } + + /* ─── Oscilloscope render ────────────────────────────────────────────────── */ + + drawOscilloscope(oscCanvas) { + if (!oscCanvas) return; + const W = oscCanvas.width || 300; + const H = oscCanvas.height || 180; + const ctx = oscCanvas.getContext('2d'); + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#06060e'; + ctx.fillRect(0, 0, W, H); + + // Grid lines + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + ctx.lineWidth = 1; + for (let x = 0; x <= W; x += W / 6) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + } + for (let y = 0; y <= H; y += H / 4) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } + // Axis labels + ctx.font = '8px Manrope,sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'left'; ctx.fillText('U (В)', 4, 10); + ctx.textAlign = 'right'; ctx.fillText('I (А)', W - 4, 10); + + const sel = this._selected !== null ? this.components.find(c => c.id === this._selected) : null; + if (!sel || !this._solution?.solved) { + ctx.font = '11px Manrope,sans-serif'; + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('Выбери компонент', W / 2, H / 2); + ctx.textBaseline = 'alphabetic'; + return; + } + + const N = 100; + const Uvals = new Float64Array(N); + const Ivals = new Float64Array(N); + + if (this._hasAC) { + // Compute 2 periods of AC signal + const freq = this.acFreq || 2; + const T = 1 / freq; + const tSpan = 2 * T; + const savedTime = this._simTime; + for (let k = 0; k < N; k++) { + this._simTime = savedTime + (k / (N - 1)) * tSpan - tSpan / 2; + this._solveOnce(); + const c = this.components.find(cc => cc.id === sel.id); + if (c) { + Uvals[k] = (c._v1 ?? 0) - (c._v2 ?? 0); + Ivals[k] = c._I ?? 0; + } + } + this._simTime = savedTime; + this._solveOnce(); // restore + } else { + // DC: horizontal lines + const U0 = (sel._v1 ?? 0) - (sel._v2 ?? 0); + const I0 = sel._I ?? 0; + Uvals.fill(U0); Ivals.fill(I0); + } + + const Umax = Math.max(...Uvals.map(Math.abs), 1e-9); + const Imax = Math.max(...Ivals.map(Math.abs), 1e-9); + const padY = 18, innerH = H - padY * 2; + + const drawLine = (vals, maxVal, color) => { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + for (let k = 0; k < N; k++) { + const px = (k / (N - 1)) * W; + const py = padY + innerH / 2 - (vals[k] / maxVal) * (innerH / 2 - 2); + k === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.stroke(); + }; + + drawLine(Uvals, Umax, '#a78bfa'); // violet for U + drawLine(Ivals, Imax, '#22d3ee'); // cyan for I + + // Scale labels + ctx.font = '8px Manrope,sans-serif'; ctx.textBaseline = 'middle'; + ctx.fillStyle = '#a78bfa'; ctx.textAlign = 'left'; + ctx.fillText(Umax.toFixed(2) + 'В', 4, padY); + ctx.fillStyle = '#22d3ee'; ctx.textAlign = 'right'; + ctx.fillText(Imax.toFixed(3) + 'А', W - 4, padY); + ctx.textBaseline = 'alphabetic'; + } + /* ─── Main draw ────────────────────────────────────────────────────────── */ draw() { @@ -951,6 +1136,7 @@ class CircuitSim { ctx.clearRect(0,0,W,H); ctx.fillStyle='#080818'; ctx.fillRect(0,0,W,H); this._drawGrid(ctx); + this._drawHeatmap(ctx); this._drawComponents(ctx); this._drawJunctions(ctx); this._drawNodeLabels(ctx); @@ -989,7 +1175,7 @@ class CircuitSim { if ((e.ctrlKey||e.metaKey)&&e.key==='z') { e.preventDefault(); this.undo(); return; } if ((e.ctrlKey||e.metaKey)&&(e.key==='y'||(e.shiftKey&&e.key==='z'))) { e.preventDefault(); this.redo(); return; } - const modeMap={w:'wire',r:'resistor',b:'battery',s:'switch',l:'lamp',c:'capacitor',d:'diode',a:'ammeter',v:'voltmeter',e:'erase'}; + const modeMap={w:'wire',r:'resistor',b:'battery',s:'switch',l:'lamp',c:'capacitor',i:'inductor',d:'diode',a:'ammeter',v:'voltmeter',e:'erase'}; const newMode=modeMap[e.key.toLowerCase()]; if (newMode) { this.addMode=newMode; if (this.onModeChange) this.onModeChange(newMode); } @@ -1139,17 +1325,19 @@ class CircuitSim { : type==='battery'||type==='ac'?this.U_value : type==='capacitor'?this.C_value : undefined; - this._add(type,x1,y1,x2,y2,value); + const L_value = type==='inductor' ? this.L_value : undefined; + this._add(type,x1,y1,x2,y2,value,L_value); this._solve(); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); } - _add(type, x1, y1, x2, y2, value) { + _add(type, x1, y1, x2, y2, value, L_value) { const id=this._nextId++; if (type==='diode'||type==='led') this._diodeR.set(id,1e9); this.components.push({ id, type, x1, y1, x2, y2, value: value??undefined, + L_value: L_value??undefined, open: false, ledColor: type==='led'?(this.ledColor||'#7BF5A4'):undefined, acFreq: type==='ac'?this.acFreq:undefined, @@ -1257,6 +1445,18 @@ class CircuitSim { this._add('wire',19,7,2,7); break; + case 'rlc': { + // RLC series: AC → R → L → C + this._add('ac',2,7,2,4,9); + this._add('wire',2,4,6,4); + this._add('resistor',6,4,10,4,10); + this._add('inductor',10,4,14,4,undefined,10); + this._add('capacitor',14,4,19,4,100); + this._add('wire',19,4,19,7); + this._add('wire',19,7,2,7); + break; + } + default: break; } @@ -1374,6 +1574,33 @@ class CircuitSim { if (cirSim) cirSim.acFreq = v; } + function circLChange() { + const v = +document.getElementById('sl-circL').value; + document.getElementById('circ-L-val').textContent = v + ' мГн'; + if (cirSim) cirSim.L_value = v; + } + + function circToggleHeat() { + if (!cirSim) return; + cirSim._heatmapOn = !cirSim._heatmapOn; + const btn = document.getElementById('ctool-heat'); + if (btn) btn.classList.toggle('active', cirSim._heatmapOn); + if (!cirSim._raf) cirSim.draw(); + } + + function circToggleOsc() { + const panel = document.getElementById('osc-panel'); + if (!panel) return; + const visible = panel.style.display !== 'none'; + panel.style.display = visible ? 'none' : 'block'; + const btn = document.getElementById('btn-osc-toggle'); + if (btn) btn.classList.toggle('active', !visible); + if (cirSim) { + cirSim._oscPanel = visible ? null : document.getElementById('osc-canvas'); + if (!visible && cirSim._oscPanel) cirSim.drawOscilloscope(cirSim._oscPanel); + } + } + function _circUpdateUI(info) { if (!info) return; document.getElementById('cirbar-comps').textContent = info.components; diff --git a/frontend/js/labs/emfield.js b/frontend/js/labs/emfield.js index f64b64c..5f7a9b7 100644 --- a/frontend/js/labs/emfield.js +++ b/frontend/js/labs/emfield.js @@ -64,6 +64,26 @@ class EMFieldSim { _dragging: false, }; + /* Gauss surface (electric flux, E / combined modes) */ + this._gauss = { + on: false, + x: 0, y: 0, + r: 70, + _dragging: false, + }; + + /* motional EMF rod (B / combined modes) */ + this._rod = { + on: false, + x1: 0, y1: 0, x2: 0, y2: 0, + vx: 0, vy: 0, // current velocity px/s + _dragging: false, + _dragOffX: 0, _dragOffY: 0, + _keys: {}, // keys held + _raf: null, + _last: 0, + }; + /* test particle */ this._particle = null; this.particleOn = false; @@ -124,6 +144,9 @@ class EMFieldSim { this._cond.x1 = this.W * 0.25; this._cond.y1 = this.H * 0.5; this._cond.x2 = this.W * 0.75; this._cond.y2 = this.H * 0.5; this._flux.x = this.W * 0.5; this._flux.y = this.H * 0.35; + this._gauss.x = this.W * 0.5; this._gauss.y = this.H * 0.5; + this._rod.x1 = this.W * 0.5; this._rod.y1 = this.H * 0.3; + this._rod.x2 = this.W * 0.5; this._rod.y2 = this.H * 0.7; } this._cmBDirty = true; @@ -185,6 +208,9 @@ class EMFieldSim { if (this._pRaf) { cancelAnimationFrame(this._pRaf); this._pRaf = null; } this._cond.on = false; this._flux.on = false; + this._gauss.on = false; + if (this._rod._raf) { cancelAnimationFrame(this._rod._raf); this._rod._raf = null; } + this._rod.on = false; this._invalidateAll(); this.draw(); if (this.onUpdate) this.onUpdate(this.info()); @@ -225,6 +251,113 @@ class EMFieldSim { this.draw(); } + /* Gauss surface (E mode, electric flux) */ + toggleGauss() { + this._gauss.on = !this._gauss.on; + this.draw(); + } + + setGaussR(r) { + this._gauss.r = r; + this.draw(); + } + + /* Motional EMF rod (B mode) */ + toggleRod() { + const rod = this._rod; + rod.on = !rod.on; + if (rod.on) { + rod.vx = 0; rod.vy = 0; + rod._last = performance.now(); + this._tickRod(); + } else { + if (rod._raf) { cancelAnimationFrame(rod._raf); rod._raf = null; } + this.draw(); + } + if (this.onUpdate) this.onUpdate(this.info()); + } + + _tickRod() { + const rod = this._rod; + if (!rod.on) return; + const now = performance.now(); + const dt = Math.min((now - rod._last) * 0.001, 0.05); // seconds + rod._last = now; + + /* keyboard-driven acceleration: arrow keys → velocity */ + const speed = 90; // px/s max + let ax = 0, ay = 0; + if (rod._keys['ArrowLeft']) ax -= 1; + if (rod._keys['ArrowRight']) ax += 1; + if (rod._keys['ArrowUp']) ay -= 1; + if (rod._keys['ArrowDown']) ay += 1; + + if (ax !== 0 || ay !== 0) { + const len = Math.hypot(ax, ay); + rod.vx = (ax / len) * speed; + rod.vy = (ay / len) * speed; + } else { + /* friction */ + rod.vx *= 0.88; + rod.vy *= 0.88; + if (Math.hypot(rod.vx, rod.vy) < 0.5) { rod.vx = 0; rod.vy = 0; } + } + + /* move rod */ + rod.x1 += rod.vx * dt; + rod.y1 += rod.vy * dt; + rod.x2 += rod.vx * dt; + rod.y2 += rod.vy * dt; + + /* clamp to canvas */ + const margin = 10; + const minX = Math.min(rod.x1, rod.x2), maxX = Math.max(rod.x1, rod.x2); + const minY = Math.min(rod.y1, rod.y2), maxY = Math.max(rod.y1, rod.y2); + if (minX < margin) { const d = margin - minX; rod.x1 += d; rod.x2 += d; } + if (maxX > this.W - margin) { const d = maxX - (this.W - margin); rod.x1 -= d; rod.x2 -= d; } + if (minY < margin) { const d = margin - minY; rod.y1 += d; rod.y2 += d; } + if (maxY > this.H - margin) { const d = maxY - (this.H - margin); rod.y1 -= d; rod.y2 -= d; } + + this.draw(); + if (this.onUpdate) this.onUpdate(this.info()); + rod._raf = requestAnimationFrame(() => this._tickRod()); + } + + /* Compute motional EMF = integral of (v × B) · dl along rod */ + _rodEMF() { + const rod = this._rod; + const Lx = rod.x2 - rod.x1, Ly = rod.y2 - rod.y1; + const L = Math.hypot(Lx, Ly); + if (L < 1) return { emf: 0, avgB: 0, v: 0 }; + /* dl unit vector */ + const dlx = Lx / L, dly = Ly / L; + const v = Math.hypot(rod.vx, rod.vy); + const N = 20; // integration samples + let sum = 0, avgB = 0; + for (let k = 0; k <= N; k++) { + const t = k / N; + const px = rod.x1 + Lx * t, py = rod.y1 + Ly * t; + const { bx, by, mag } = this._bField(px, py); + avgB += mag; + /* (v × B) in 2D: vx*By - vy*Bx gives z-component of (v×B) + (v×B)·dl = (vx·By - vy·Bx)·dlx - ... → in 2D project back: + (v×B) is a vector: if v=(vx,vy,0), B=(Bx,By,0) → + v×B = (vy·0-0·By, 0·Bx-vx·0, vx·By-vy·Bx) = (0,0,vx·By-vy·Bx) + But B here is in-plane; we treat |B| as out-of-plane Bz for the 2D sim. + So B = (0,0,Bz) where Bz = mag (or -mag depending on orientation sign). + We use bx,by as in-plane → but physically they represent the field in the plane. + For motional EMF in 2D: use Bz=mag (perpendicular to plane convention). + (v×Bz_hat)·dl = (vy·Bz)·dlx + (-vx·Bz)·dly */ + const Beff = mag * 0.00012; // same scale used in particle simulation + const vCrossB_x = rod.vy * Beff; + const vCrossB_y = -rod.vx * Beff; + sum += (vCrossB_x * dlx + vCrossB_y * dly); + } + avgB /= (N + 1); + const emf = sum * L / (N + 1); // Riemann sum → integral + return { emf, avgB, v }; + } + /* ────────────────────────────── Particle ────────────────────────────── */ @@ -367,6 +500,18 @@ class EMFieldSim { } break; } + case 'toroid': { + /* toroid cross-section: inner ring (wire-out) + outer ring (wire-in) + This approximates a toroid where B is confined inside the winding. + 16 wire-out at radius r1, 16 wire-in at radius r2 (concentric). */ + const n = 16, r1 = 75, r2 = 130; + for (let i = 0; i < n; i++) { + const a = (i / n) * Math.PI * 2; + this._pushWire(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1, 'out'); + this._pushWire(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2, 'in'); + } + break; + } } this._invalidateAll(); this.draw(); @@ -466,18 +611,50 @@ class EMFieldSim { const out = wires.filter(w => w.I > 0).length; const inn = wires.filter(w => w.I < 0).length; - const condOn = this._cond.on; - const fluxOn = this._flux.on; - const ampere = condOn ? this._ampereForce() : null; - const Fz = ampere ? ampere.Fz : 0; - const flux = fluxOn ? this._fluxValue() : 0; + const condOn = this._cond.on; + const fluxOn = this._flux.on; + const gaussOn = this._gauss.on; + const rodOn = this._rod.on; + const ampere = condOn ? this._ampereForce() : null; + const Fz = ampere ? ampere.Fz : 0; + const flux = fluxOn ? this._fluxValue() : 0; + + /* Gauss surface: exact (sum q_enc) + numerical */ + let gaussExact = 0, gaussNumerical = 0; + if (gaussOn && this.mode !== 'B') { + const g = this._gauss; + const eps0inv = 1 / (4 * Math.PI * this.K_E); // 1/ε₀ in visual units + for (const s of this.sources) { + if (s.kind !== 'charge') continue; + if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) gaussExact += s.q; + } + gaussExact *= eps0inv; // Gauss: Φ = q_enc / ε₀ + /* numerical line integral ∮ E·n ds */ + const N = 64; + for (let k = 0; k < N; k++) { + const a = (k / N) * Math.PI * 2; + const px = g.x + g.r * Math.cos(a), py = g.y + g.r * Math.sin(a); + const { ex, ey } = this._eField(px, py); + const nx = Math.cos(a), ny = Math.sin(a); // outward normal + gaussNumerical += (ex * nx + ey * ny) * g.r * (2 * Math.PI / N); + } + } + + /* Rod EMF */ + let rodEMF = 0, rodV = 0, rodAvgB = 0; + if (rodOn) { + const r = this._rodEMF(); + rodEMF = r.emf; rodV = r.v; rodAvgB = r.avgB; + } return { total: this.sources.length, charges: charges.length, pos, neg, wires: wires.length, out, inn, particleOn: this.particleOn, - condOn, fluxOn, Fz, flux, + condOn, fluxOn, gaussOn, rodOn, Fz, flux, + gaussExact, gaussNumerical, + rodEMF, rodV, rodAvgB, cursorE: this._cursorE ? this._cursorE.mag.toFixed(0) : '—', cursorV: this._cursorE ? this._cursorE.v.toFixed(0) : '—', cursorB: this._cursorB ? this._cursorB.mag.toFixed(0) : '—', @@ -519,6 +696,18 @@ class EMFieldSim { return Math.hypot(p.x - this._flux.x, p.y - this._flux.y) < this._flux.r + 12; }; + const hitGauss = p => { + if (!this._gauss.on) return false; + return Math.hypot(p.x - this._gauss.x, p.y - this._gauss.y) < this._gauss.r + 12; + }; + + const hitRod = p => { + if (!this._rod.on) return false; + const { x1, y1, x2, y2 } = this._rod; + const mx = (x1 + x2) / 2, my = (y1 + y2) / 2; + return Math.hypot(p.x - mx, p.y - my) < Math.hypot(x2 - x1, y2 - y1) / 2 + 14; + }; + let _condDragOffset = null; c.addEventListener('mousedown', e => { @@ -543,6 +732,19 @@ class EMFieldSim { c.style.cursor = 'grabbing'; return; } + if (hitGauss(p)) { + this._gauss._dragging = true; + c.style.cursor = 'grabbing'; return; + } + + if (hitRod(p)) { + const rod = this._rod; + rod._dragging = true; + rod._dragOffX = p.x - (rod.x1 + rod.x2) / 2; + rod._dragOffY = p.y - (rod.y1 + rod.y2) / 2; + c.style.cursor = 'grabbing'; return; + } + const i = hitSource(p); if (i >= 0) { this._drag = i; c.style.cursor = 'grabbing'; } }); @@ -588,6 +790,20 @@ class EMFieldSim { this.draw(); return; } + if (this._gauss._dragging) { + this._gauss.x = p.x; this._gauss.y = p.y; + this.draw(); return; + } + + if (this._rod._dragging) { + const rod = this._rod; + const cx = p.x - rod._dragOffX, cy = p.y - rod._dragOffY; + const hLx = (rod.x2 - rod.x1) / 2, hLy = (rod.y2 - rod.y1) / 2; + rod.x1 = cx - hLx; rod.y1 = cy - hLy; + rod.x2 = cx + hLx; rod.y2 = cy + hLy; + this.draw(); return; + } + if (this._drag !== null) { this.sources[this._drag].x = p.x; this.sources[this._drag].y = p.y; @@ -597,7 +813,7 @@ class EMFieldSim { const i = hitSource(p); const ch = hitCond(p); - const fh = hitFlux(p); + const fh = hitFlux(p) || hitGauss(p) || hitRod(p); this._hovered = i >= 0 ? i : null; c.style.cursor = (i >= 0 || ch !== null || fh) ? 'grab' : 'crosshair'; this.draw(); @@ -614,6 +830,12 @@ class EMFieldSim { if (this._flux._dragging) { this._flux._dragging = false; c.style.cursor = 'crosshair'; return; } + if (this._gauss._dragging) { + this._gauss._dragging = false; c.style.cursor = 'crosshair'; return; + } + if (this._rod._dragging) { + this._rod._dragging = false; c.style.cursor = 'crosshair'; return; + } if (this._drag !== null) { this._invalidateAll(); this._drag = null; c.style.cursor = 'crosshair'; @@ -622,7 +844,7 @@ class EMFieldSim { /* click on empty canvas — add source based on mode */ if (!moved && e.button === 0 && - hitSource(p) < 0 && hitCond(p) === null && !hitFlux(p)) { + hitSource(p) < 0 && hitCond(p) === null && !hitFlux(p) && !hitGauss(p) && !hitRod(p)) { if (this.mode === 'E') { this.addCharge(p.x, p.y, this.addSign); } else if (this.mode === 'B') { @@ -686,6 +908,18 @@ class EMFieldSim { this._drag = null; if (this.onUpdate) this.onUpdate(this.info()); }, { passive: false }); + + /* arrow-key control for rod */ + document.addEventListener('keydown', e => { + if (!this._rod.on) return; + if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)) { + e.preventDefault(); + this._rod._keys[e.key] = true; + } + }); + document.addEventListener('keyup', e => { + delete this._rod._keys[e.key]; + }); } /* ────────────────────────────── @@ -729,6 +963,8 @@ class EMFieldSim { /* overlays */ if (this._flux.on && this.mode !== 'E') this._drawFlux(ctx); if (this._cond.on && this.mode !== 'E') this._drawConductor(ctx); + if (this._gauss.on && this.mode !== 'B') this._drawGauss(ctx); + if (this._rod.on && this.mode !== 'E') this._drawRod(ctx); if (this._particle) this._drawParticle(ctx); /* sources */ @@ -1238,6 +1474,129 @@ class EMFieldSim { ctx.restore(); } + /* ── Gauss surface (electric flux) ── */ + _drawGauss(ctx) { + const g = this._gauss; + /* compute enclosed charge and numerical flux */ + const eps0inv = 1 / (4 * Math.PI * this.K_E); + let qEnc = 0; + for (const s of this.sources) { + if (s.kind !== 'charge') continue; + if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) qEnc += s.q; + } + const phiExact = qEnc * eps0inv; + + /* draw enclosed charge halo */ + ctx.save(); + for (const s of this.sources) { + if (s.kind !== 'charge') continue; + if (Math.hypot(s.x - g.x, s.y - g.y) < g.r) { + ctx.beginPath(); ctx.arc(s.x, s.y, 26, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(52,211,153,0.55)'; ctx.lineWidth = 3; + ctx.shadowColor = '#34d399'; ctx.shadowBlur = 12; ctx.stroke(); + } + } + ctx.restore(); + + /* background fill */ + ctx.save(); + const grad = ctx.createRadialGradient(g.x, g.y, 0, g.x, g.y, g.r); + const a = Math.min(0.35, Math.abs(phiExact) * 0.008 + 0.05); + grad.addColorStop(0, `rgba(52,211,153,${a})`); + grad.addColorStop(1, 'transparent'); + ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, Math.PI * 2); ctx.fill(); + + /* dashed circle with flowing dash-offset to suggest surface motion */ + ctx.setLineDash([10, 6]); + ctx.strokeStyle = 'rgba(52,211,153,0.85)'; ctx.lineWidth = 2; + ctx.shadowColor = '#34d399'; ctx.shadowBlur = 10; + ctx.beginPath(); ctx.arc(g.x, g.y, g.r, 0, Math.PI * 2); ctx.stroke(); + ctx.setLineDash([]); + ctx.shadowBlur = 0; + + /* normal arrows on circle */ + const nArr = 12; + ctx.strokeStyle = 'rgba(52,211,153,0.5)'; ctx.fillStyle = 'rgba(52,211,153,0.7)'; ctx.lineWidth = 1.2; + for (let k = 0; k < nArr; k++) { + const a2 = (k / nArr) * Math.PI * 2; + const ex = Math.cos(a2), ey = Math.sin(a2); + const rx = g.x + g.r * ex, ry = g.y + g.r * ey; + const len = phiExact !== 0 ? (phiExact > 0 ? 14 : -14) : 10; + const x2 = rx + ex * len, y2 = ry + ey * len; + ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(x2, y2); ctx.stroke(); + const ang = Math.atan2(ey, ex); + ctx.save(); ctx.translate(x2, y2); ctx.rotate(ang); + ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-5, -3); ctx.lineTo(-5, 3); + ctx.closePath(); ctx.fill(); ctx.restore(); + } + + /* label */ + ctx.font = 'bold 11px Manrope, sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#34d399'; ctx.shadowColor = '#34d399'; ctx.shadowBlur = 6; + const signStr = phiExact >= 0 ? '+' : ''; + ctx.fillText('Φₑ = ' + signStr + phiExact.toFixed(3) + ' (точн.)', g.x, g.y + g.r + 6); + ctx.font = '10px Manrope, sans-serif'; ctx.fillStyle = 'rgba(52,211,153,0.7)'; ctx.shadowBlur = 3; + ctx.fillText('qₑₙₙ = ' + qEnc.toFixed(1) + ' | перетащи', g.x, g.y + g.r + 20); + ctx.restore(); + } + + /* ── motional EMF rod ── */ + _drawRod(ctx) { + const rod = this._rod; + const Lx = rod.x2 - rod.x1, Ly = rod.y2 - rod.y1; + const L = Math.hypot(Lx, Ly); + if (L < 2) return; + const { emf, avgB, v } = this._rodEMF(); + const mx = (rod.x1 + rod.x2) / 2, my = (rod.y1 + rod.y2) / 2; + + ctx.save(); + + /* velocity arrow */ + if (v > 0.5) { + const spd = Math.min(50, v * 0.5); + const vx = rod.vx / v, vy = rod.vy / v; + const ax2 = mx + vx * spd, ay2 = my + vy * spd; + ctx.strokeStyle = '#a78bfa'; ctx.lineWidth = 2; ctx.shadowColor = '#a78bfa'; ctx.shadowBlur = 8; + ctx.beginPath(); ctx.moveTo(mx, my); ctx.lineTo(ax2, ay2); ctx.stroke(); + const ang = Math.atan2(vy, vx); + ctx.save(); ctx.translate(ax2, ay2); ctx.rotate(ang); + ctx.fillStyle = '#a78bfa'; + ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(-8,-4); ctx.lineTo(-8,4); ctx.closePath(); ctx.fill(); + ctx.restore(); + ctx.font = '10px Manrope'; ctx.fillStyle = '#a78bfa'; + ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; + ctx.fillText('v', ax2, ay2 - 6); + } + + /* rod itself */ + ctx.shadowColor = '#f59e0b'; ctx.shadowBlur = 16; + ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 5; ctx.globalAlpha = 0.35; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(rod.x1, rod.y1); ctx.lineTo(rod.x2, rod.y2); ctx.stroke(); + ctx.globalAlpha = 1; ctx.shadowBlur = 8; ctx.lineWidth = 3.5; + ctx.strokeStyle = '#f59e0b'; + ctx.beginPath(); ctx.moveTo(rod.x1, rod.y1); ctx.lineTo(rod.x2, rod.y2); ctx.stroke(); + + /* endpoints */ + [[rod.x1,rod.y1],[rod.x2,rod.y2]].forEach(([ex,ey]) => { + ctx.beginPath(); ctx.arc(ex, ey, 7, 0, Math.PI * 2); + ctx.fillStyle = '#f59e0b'; ctx.shadowBlur = 10; ctx.fill(); + ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke(); + }); + + /* EMF label */ + const perpX = -Ly / L, perpY = Lx / L; + ctx.shadowBlur = 6; ctx.shadowColor = '#f59e0b'; + ctx.font = 'bold 11px Manrope'; ctx.fillStyle = '#f59e0b'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('ε = ' + emf.toFixed(4) + ' (ед)', mx + perpX * 26, my + perpY * 26); + ctx.font = '10px Manrope'; ctx.fillStyle = 'rgba(245,158,11,0.75)'; ctx.shadowBlur = 3; + ctx.fillText('|B|̲ = ' + avgB.toFixed(1) + ' v = ' + v.toFixed(1), mx + perpX * 26, my + perpY * 40); + ctx.fillText('← ↑ → ↓ — перемещение', mx, my - L / 2 - 14); + + ctx.restore(); + } + /* ── particle ── */ _drawParticle(ctx) { const p = this._particle; @@ -1510,6 +1869,44 @@ function emFluxToggle(rowEl) { function emPresetE(name) { if (emSim) emSim.presetE(name); } function emPresetB(name) { if (emSim) emSim.presetB(name); } +function emGaussToggle(rowEl) { + if (!emSim) return; + emSim.toggleGauss(); + const on = emSim._gauss.on; + rowEl.classList.toggle('active', on); + const tog = rowEl.querySelector('.tri-toggle'); + if (tog) { + tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)'; + const dot = tog.querySelector('span'); + if (dot) dot.style.marginLeft = on ? '14px' : '2px'; + } + const block = document.getElementById('em-gauss-r-block'); + if (block) block.style.display = on ? '' : 'none'; + _emUpdateUI(emSim.info()); +} + +function emGaussRChange() { + if (!emSim) return; + const r = parseFloat(document.getElementById('sl-emGaussR').value); + const lbl = document.getElementById('em-gaussR-val'); + if (lbl) lbl.textContent = Math.round(r) + ' пкс'; + emSim.setGaussR(r); +} + +function emRodToggle(rowEl) { + if (!emSim) return; + emSim.toggleRod(); + const on = emSim._rod.on; + rowEl.classList.toggle('active', on); + const tog = rowEl.querySelector('.tri-toggle'); + if (tog) { + tog.style.background = on ? 'var(--violet)' : 'rgba(255,255,255,0.12)'; + const dot = tog.querySelector('span'); + if (dot) dot.style.marginLeft = on ? '14px' : '2px'; + } + _emUpdateUI(emSim.info()); +} + function _emUpdateUI(info) { if (!info) return; const set = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; }; @@ -1527,7 +1924,7 @@ function _emUpdateUI(info) { const fEl = document.getElementById('embar-ampere'); if (fEl) { if (info.condOn && info.Fz !== 0) { - fEl.textContent = (info.Fz > 0 ? '⊙ ' : '⊗ ') + Math.abs(info.Fz).toFixed(3); + fEl.textContent = (info.Fz > 0 ? '(+) ' : '(-) ') + Math.abs(info.Fz).toFixed(3); fEl.style.color = '#fbbf24'; } else { fEl.textContent = '—'; fEl.style.color = '#fbbf24'; @@ -1538,4 +1935,27 @@ function _emUpdateUI(info) { if (info.fluxOn) { phEl.textContent = info.flux.toExponential(2) + ' Вб'; phEl.style.color = '#34d399'; } else { phEl.textContent = '—'; phEl.style.color = '#34d399'; } } + + /* Gauss surface stats */ + const gEl = document.getElementById('embar-gauss'); + if (gEl) { + if (info.gaussOn) { + const sign = info.gaussExact >= 0 ? '+' : ''; + gEl.textContent = sign + info.gaussExact.toFixed(3); + gEl.style.color = '#34d399'; + } else { + gEl.textContent = '—'; gEl.style.color = '#34d399'; + } + } + + /* Rod EMF stats */ + const rEl = document.getElementById('embar-rod'); + if (rEl) { + if (info.rodOn) { + rEl.textContent = info.rodEMF.toFixed(4) + ' ед'; + rEl.style.color = '#f59e0b'; + } else { + rEl.textContent = '—'; rEl.style.color = '#f59e0b'; + } + } } diff --git a/frontend/js/labs/geometry.js b/frontend/js/labs/geometry.js index adc437d..3763a59 100644 --- a/frontend/js/labs/geometry.js +++ b/frontend/js/labs/geometry.js @@ -3240,3 +3240,464 @@ class GeoSim { /* ── trig circle ── */ + /* ══════════════════════════════════════════════════════════════════════ + ЗАДАЧНИК — challenge framework + ══════════════════════════════════════════════════════════════════════ */ + + /** + * Helper: get two math-coordinate points on any line-like object. + * Works for 'segment', 'line', 'ray', 'derived_line'. + */ + function _challTwoPts(eng, obj) { + if (!obj) return null; + if (obj.type === 'derived_line') { + return [{ x: obj.ptX, y: obj.ptY }, + { x: obj.ptX + obj.dirX, y: obj.ptY + obj.dirY }]; + } + const p1 = eng.get(obj.p1Id), p2 = eng.get(obj.p2Id); + if (!p1 || !p2) return null; + return [{ x: p1.x, y: p1.y }, { x: p2.x, y: p2.y }]; + } + + /** + * Find all "line-like" objects (line, ray, segment, derived_line). + */ + function _challLines(eng) { + return eng.all().filter(o => + o.type === 'line' || o.type === 'ray' || + o.type === 'segment' || o.type === 'derived_line' + ); + } + + /** + * Normalise direction: always returns { dx, dy } with dy >= 0 + * (or dx > 0 when dy == 0), for comparing line directions. + */ + function _challNormDir(dx, dy) { + const len = Math.hypot(dx, dy); + if (len < 1e-12) return { dx: 0, dy: 0 }; + let nx = dx / len, ny = dy / len; + if (ny < 0 || (Math.abs(ny) < 1e-9 && nx < 0)) { nx = -nx; ny = -ny; } + return { dx: nx, dy: ny }; + } + + const CHALLENGES = [ + /* ── C1: Серединный перпендикуляр ──────────────────────────────── */ + { + id: 'C1', + title: 'Серединный перпендикуляр к AB', + desc: 'Постройте серединный перпендикуляр к отрезку AB. ' + + 'Используйте инструмент «⊥ биссект.» или постройте вручную ' + + '(прямую через середину AB, перпендикулярную AB).', + hint: 'Воспользуйтесь инструментом «⊥ биссект.» — кликните точки A и B.', + setup(eng) { + eng.clear(); + const A = eng.add({ type:'point', x:-2, y:0, label:'A' }); + const B = eng.add({ type:'point', x: 2, y:0, label:'B' }); + eng.add({ type:'segment', p1Id:A.id, p2Id:B.id }); + }, + check(eng) { + // Find A and B by label + const pts = eng.points(); + const A = pts.find(p => p.label === 'A'); + const B = pts.find(p => p.label === 'B'); + if (!A || !B) return { passed: false, hint: 'Не найдены точки A и B.' }; + + const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; + const segDx = B.x - A.x, segDy = B.y - A.y; + const segLen = Math.hypot(segDx, segDy); + if (segLen < 1e-9) return { passed: false }; + + // Perpendicular direction to AB + const perpDx = -segDy / segLen, perpDy = segDx / segLen; + + for (const obj of _challLines(eng)) { + const pts2 = _challTwoPts(eng, obj); + if (!pts2) continue; + const [P1, P2] = pts2; + const dx = P2.x - P1.x, dy = P2.y - P1.y; + const len2 = Math.hypot(dx, dy); + if (len2 < 1e-9) continue; + + // Check direction is perpendicular to AB (dot product with AB dir ≈ 0) + const dot = (dx / len2) * segDx / segLen + (dy / len2) * segDy / segLen; + if (Math.abs(dot) > 0.02) continue; // not perpendicular + + // Check passes through midpoint + const distToMid = gDistToLine(mid, P1, P2); + if (distToMid < 0.05) return { passed: true }; + } + return { passed: false, + hint: 'Нужна прямая, проходящая через середину AB и перпендикулярная AB.' }; + } + }, + + /* ── C2: Биссектриса угла ───────────────────────────────────────── */ + { + id: 'C2', + title: 'Биссектриса угла', + desc: 'Постройте биссектрису угла с вершиной V. ' + + 'Используйте инструмент «∠ биссект.» (три клика: A, вершина V, B).', + hint: 'Инструмент «∠ биссект.»: кликните точку A, затем V (вершину), затем B.', + setup(eng) { + eng.clear(); + const V = eng.add({ type:'point', x:0, y:0, label:'V' }); + const A = eng.add({ type:'point', x:-3, y:0, label:'A' }); + const B = eng.add({ type:'point', x:0, y:3, label:'B' }); + eng.add({ type:'ray', p1Id:V.id, p2Id:A.id }); + eng.add({ type:'ray', p1Id:V.id, p2Id:B.id }); + }, + check(eng) { + const pts = eng.points(); + const V = pts.find(p => p.label === 'V'); + const A = pts.find(p => p.label === 'A'); + const B = pts.find(p => p.label === 'B'); + if (!V || !A || !B) return { passed: false, hint: 'Не найдены точки V, A, B.' }; + + // Expected bisector direction + const va = gNorm({ x: A.x - V.x, y: A.y - V.y }); + const vb = gNorm({ x: B.x - V.x, y: B.y - V.y }); + const bisDir = gNorm({ x: va.x + vb.x, y: va.y + vb.y }); + if (Math.hypot(bisDir.x, bisDir.y) < 1e-9) + return { passed: false, hint: 'Угол вырожден.' }; + + // Half-angle for tolerance: ±0.5° + const halfAngleDeg = gAngleDeg(A, V, B) / 2; + const TOL_DEG = 0.5; + + for (const obj of _challLines(eng)) { + const pts2 = _challTwoPts(eng, obj); + if (!pts2) continue; + const [P1, P2] = pts2; + + // Must pass through V + const distV = gDistToLine({ x: V.x, y: V.y }, P1, P2); + if (distV > 0.08) continue; + + // Direction must match bisector + const dx = P2.x - P1.x, dy = P2.y - P1.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) continue; + const crossAbs = Math.abs((dx / len) * bisDir.y - (dy / len) * bisDir.x); + // sin(angle between lines) = crossAbs; for ±0.5° sin(0.5°) ≈ 0.0087 + if (crossAbs < Math.sin(TOL_DEG * Math.PI / 180)) return { passed: true }; + } + return { passed: false, + hint: 'Нужен луч/прямая из V, делящая угол AVB пополам.' }; + } + }, + + /* ── C3: Описанная окружность вокруг треугольника ───────────────── */ + { + id: 'C3', + title: 'Описанная окружность треугольника', + desc: 'Постройте окружность, проходящую через все три вершины треугольника ABC. ' + + 'Используйте инструмент «Описанная» (circumcircle).', + hint: 'Инструмент «Описанная»: кликните три вершины A, B, C — окружность строится автоматически.', + setup(eng) { + eng.clear(); + const A = eng.add({ type:'point', x:-2, y:-1.5, label:'A' }); + const B = eng.add({ type:'point', x: 2, y:-1.5, label:'B' }); + const C = eng.add({ type:'point', x: 0, y: 2, label:'C' }); + eng.add({ type:'polygon', pointIds:[A.id, B.id, C.id] }); + }, + check(eng) { + const pts = eng.points(); + const A = pts.find(p => p.label === 'A'); + const B = pts.find(p => p.label === 'B'); + const C = pts.find(p => p.label === 'C'); + if (!A || !B || !C) return { passed: false, hint: 'Не найдены вершины A, B, C.' }; + + // Look for any circle passing through A, B, C within 1% + for (const circ of eng.byType('circle')) { + let cx, cy, r; + if (circ.derived && circ.cx != null) { + cx = circ.cx; cy = circ.cy; r = circ.r; + } else { + const ctr = eng.get(circ.centerId); + const edg = eng.get(circ.edgeId); + if (!ctr || !edg) continue; + cx = ctr.x; cy = ctr.y; + r = gDist({ x: cx, y: cy }, { x: edg.x, y: edg.y }); + } + if (r < 1e-9) continue; + const O = { x: cx, y: cy }; + const rA = gDist(O, A), rB = gDist(O, B), rC = gDist(O, C); + const tol = r * 0.05; // 5% — generous for hand-built circumcircles + if (Math.abs(rA - r) < tol && Math.abs(rB - r) < tol && Math.abs(rC - r) < tol) + return { passed: true }; + } + return { passed: false, + hint: 'Постройте окружность, равноудалённую от A, B и C.' }; + } + }, + + /* ── C4: ГМТ — множество точек, равноудалённых от A и B ────────── */ + { + id: 'C4', + title: 'ГМТ: равноудалённые от A и B', + desc: 'Постройте геометрическое место точек, равноудалённых от точек A и B. ' + + 'Подсказка: это серединный перпендикуляр AB. ' + + 'Используйте инструмент «ГМТ» (locus): ' + + 'создайте скользящую точку на окружности или отрезке, ' + + 'затем постройте из неё точку-цель, равноудалённую от A и B.', + hint: 'Самый простой способ: серединный перпендикуляр к AB — это и есть ГМТ. ' + + 'Используйте инструмент «⊥ биссект.» или locus.', + setup(eng) { + eng.clear(); + const A = eng.add({ type:'point', x:-2, y:0, label:'A' }); + const B = eng.add({ type:'point', x: 2, y:0, label:'B' }); + }, + check(eng) { + // Accept: any locus object OR any line that is the perpendicular bisector of AB + const pts = eng.points(); + const A = pts.find(p => p.label === 'A'); + const B = pts.find(p => p.label === 'B'); + if (!A || !B) return { passed: false }; + + // Accept a locus object (heuristic: it exists) + if (eng.byType('locus').length > 0) return { passed: true }; + + // Accept a perpendicular bisector line/derived_line through midpoint + const mid = { x: (A.x + B.x) / 2, y: (A.y + B.y) / 2 }; + const segLen = gDist(A, B); + if (segLen < 1e-9) return { passed: false }; + const segDx = (B.x - A.x) / segLen, segDy = (B.y - A.y) / segLen; + + for (const obj of _challLines(eng)) { + const pts2 = _challTwoPts(eng, obj); + if (!pts2) continue; + const [P1, P2] = pts2; + const dx = P2.x - P1.x, dy = P2.y - P1.y; + const len = Math.hypot(dx, dy); + if (len < 1e-9) continue; + const dot = Math.abs((dx / len) * segDx + (dy / len) * segDy); + if (dot > 0.02) continue; // not perpendicular to AB + if (gDistToLine(mid, P1, P2) < 0.08) return { passed: true }; + } + return { passed: false, + hint: 'Постройте серединный перпендикуляр к AB или используйте инструмент ГМТ.' }; + } + }, + + /* ── C5: Касательная к окружности ──────────────────────────────── */ + { + id: 'C5', + title: 'Касательная к окружности', + desc: 'Постройте касательную к окружности из внешней точки P. ' + + 'Используйте инструмент «Касательные» (tangent): кликните на окружность, ' + + 'затем на внешнюю точку P.', + hint: 'Инструмент «Касательные»: сначала кликните на окружность, потом на точку P.', + setup(eng) { + eng.clear(); + const center = eng.add({ type:'point', x: 0, y: 0, label:'O' }); + const edge = eng.add({ type:'point', x: 2, y: 0, label:'R' }); + eng.add({ type:'circle', centerId: center.id, edgeId: edge.id }); + eng.add({ type:'point', x: 5, y: 0, label:'P' }); + }, + check(eng) { + // Find the setup circle and P + const pts = eng.points(); + const O = pts.find(p => p.label === 'O'); + const P = pts.find(p => p.label === 'P'); + if (!O || !P) return { passed: false }; + + // Find circle with center O + let circR = null; + for (const circ of eng.byType('circle')) { + const ctr = eng.get(circ.centerId); + if (ctr && Math.abs(ctr.x - O.x) < 0.01 && Math.abs(ctr.y - O.y) < 0.01) { + const edg = eng.get(circ.edgeId); + if (edg) { circR = gDist({ x: O.x, y: O.y }, { x: edg.x, y: edg.y }); break; } + } + } + if (!circR) return { passed: false, hint: 'Исходная окружность не найдена.' }; + + const Opt = { x: O.x, y: O.y }; + const TOL = circR * 0.05; // 5% of radius + + // Look for any line/ray through P where distance from O to line ≈ radius + for (const obj of _challLines(eng)) { + const pts2 = _challTwoPts(eng, obj); + if (!pts2) continue; + const [P1, P2] = pts2; + + // Must pass through P (within tolerance) + const distP = gDistToLine({ x: P.x, y: P.y }, P1, P2); + if (distP > 0.15) continue; + + // Distance from center O to the line ≈ radius + const distO = gDistToLine(Opt, P1, P2); + if (Math.abs(distO - circR) < TOL) return { passed: true }; + } + return { passed: false, + hint: 'Постройте прямую через P, касающуюся окружности с центром O.' }; + } + }, + ]; + + /* ── Challenge state ─────────────────────────────────────────────── */ + let _challState = CHALLENGES.map(() => 'locked'); + _challState[0] = 'current'; // first challenge is unlocked + let _challAttempts = CHALLENGES.map(() => 0); // fail attempt counter + let _challPanelOpen = false; + + function geoToggleChallengePanel() { + _challPanelOpen = !_challPanelOpen; + const panel = document.getElementById('geo-challenge-panel'); + if (panel) panel.classList.toggle('open', _challPanelOpen); + if (_challPanelOpen) _geoChallRenderList(); + } + + function _geoChallRenderList() { + const list = document.getElementById('geo-chall-list'); + if (!list) return; + list.innerHTML = ''; + const doneCount = _challState.filter(s => s === 'done').length; + const countEl = document.getElementById('geo-chall-count'); + if (countEl) countEl.textContent = doneCount + '/' + CHALLENGES.length; + + CHALLENGES.forEach((ch, idx) => { + const state = _challState[idx]; + const item = document.createElement('div'); + item.className = 'geo-chall-item chall-' + state; + item.dataset.idx = idx; + + // Status icon + let statusContent = (idx + 1).toString(); + if (state === 'done') { + statusContent = ''; + } else if (state === 'locked') { + statusContent = ''; + } + + item.innerHTML = ` +
+
${statusContent}
+
${ch.title}
+
+
+
${ch.desc}
+
+ + +
+
${ch.hint}
+ +
`; + + // Allow expanding done items too + item.querySelector('.geo-chall-head').addEventListener('click', () => { + item.classList.toggle('geo-chall-expanded'); + }); + + list.appendChild(item); + }); + } + + function geoChallSetup(idx) { + if (!geomSim) return; + const ch = CHALLENGES[idx]; + if (!ch) return; + ch.setup(geomSim.eng); + // Recompute all derived objects after setup + for (const obj of geomSim.eng.all()) { + if (obj.derived) geomSim.eng.recompute(obj.id); + } + geomSim.fit(); + geomSim.render(); + _geoUpdateStats(); + const fb = document.getElementById('geo-chall-fb-' + idx); + if (fb) { fb.textContent = ''; fb.className = 'geo-chall-feedback'; } + } + + function geoChallCheck(idx) { + if (!geomSim) return; + const ch = CHALLENGES[idx]; + if (!ch || _challState[idx] === 'locked') return; + + const result = ch.check(geomSim.eng); + const fb = document.getElementById('geo-chall-fb-' + idx); + + if (result.passed) { + _challState[idx] = 'done'; + // Unlock next + if (idx + 1 < CHALLENGES.length && _challState[idx + 1] === 'locked') { + _challState[idx + 1] = 'current'; + } + if (fb) { fb.textContent = 'Верно!'; fb.className = 'geo-chall-feedback ok'; } + _geoChallSuccessBurst(); + _geoChallRenderList(); + } else { + _challAttempts[idx]++; + const msg = result.hint + ? result.hint + : 'Не совсем. Попробуй ещё раз.'; + if (fb) { fb.textContent = msg; fb.className = 'geo-chall-feedback err'; } + // Show hint after 2 fails + if (_challAttempts[idx] >= 2) { + const hintEl = document.getElementById('geo-chall-hint-' + idx); + if (hintEl) hintEl.classList.add('visible'); + } + } + } + + function _geoChallSuccessBurst() { + const outer = document.querySelector('.geo-canvas-outer'); + if (!outer) return; + + // "Молодец!" label + const label = document.createElement('div'); + label.className = 'geo-chall-success-label'; + label.textContent = 'Молодец!'; + outer.appendChild(label); + setTimeout(() => label.remove(), 2400); + + // Confetti particles on canvas + if (!geomSim) return; + const canvas = geomSim.canvas; + const ctx = geomSim.ctx; + const W = canvas.width, H = canvas.height; + const particles = []; + const colors = ['#4ADE80', '#34D399', '#A78BFA', '#60A5FA', '#FBBF24', '#F472B6']; + for (let i = 0; i < 60; i++) { + particles.push({ + x: W / 2 + (Math.random() - 0.5) * W * 0.4, + y: H / 2 + (Math.random() - 0.5) * H * 0.3, + vx: (Math.random() - 0.5) * 5, + vy: (Math.random() - 0.6) * 6, + r: 3 + Math.random() * 4, + color: colors[Math.floor(Math.random() * colors.length)], + alpha: 1, + rot: Math.random() * Math.PI * 2, + rotV: (Math.random() - 0.5) * 0.3, + }); + } + + let frame = 0; + const maxFrames = 60; + function burst() { + if (frame >= maxFrames) { geomSim.render(); return; } + geomSim.render(); + for (const p of particles) { + ctx.save(); + ctx.globalAlpha = p.alpha; + ctx.translate(p.x, p.y); + ctx.rotate(p.rot); + ctx.fillStyle = p.color; + ctx.fillRect(-p.r / 2, -p.r / 2, p.r, p.r * 1.6); + ctx.restore(); + p.x += p.vx; + p.y += p.vy; + p.vy += 0.18; // gravity + p.alpha -= 1 / maxFrames; + p.rot += p.rotV; + } + frame++; + requestAnimationFrame(burst); + } + requestAnimationFrame(burst); + } + diff --git a/frontend/js/labs/stereo.js b/frontend/js/labs/stereo.js index d336e93..3dcf1e1 100644 --- a/frontend/js/labs/stereo.js +++ b/frontend/js/labs/stereo.js @@ -43,7 +43,7 @@ class StereoSim { this._clickStart = { x: e.clientX, y: e.clientY }; this._drag = true; this._prevX = e.clientX; this._prevY = e.clientY; this._autoSpin = false; this._idleTime = 0; - if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode) el.style.cursor = 'grabbing'; + if (!this._pointMode && !this._connectMode && !this._measureMode && !this._angleMode && !this._section3PMode) el.style.cursor = 'grabbing'; }); window.addEventListener('pointerup', e => { const wasDrag = this._clickStart && @@ -55,6 +55,7 @@ class StereoSim { else if (this._angleMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onAngleClick(e); } else if (this._markMode) { el.style.cursor = 'pointer'; if (!wasDrag) this._onMarkClick(e); } else if (this._deriveMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onDeriveClick(e); } + else if (this._section3PMode) { el.style.cursor = 'crosshair'; if (!wasDrag) this._onSection3PClick(e); } else el.style.cursor = 'grab'; }); window.addEventListener('pointermove', e => { @@ -120,6 +121,7 @@ class StereoSim { this._gridGroup = new THREE.Group(); this._markGroup = new THREE.Group(); this._derivedGroup = new THREE.Group(); + this._section3PGroup = new THREE.Group(); this.scene.add(this._gridGroup); this.scene.add(this._figGroup); this.scene.add(this._sectionGroup); @@ -128,6 +130,7 @@ class StereoSim { this.scene.add(this._measurePickGroup); this.scene.add(this._markGroup); this.scene.add(this._derivedGroup); + this.scene.add(this._section3PGroup); this.scene.add(this._labelGroup); /* state */ @@ -196,6 +199,13 @@ class StereoSim { /* edge length labels */ this.showEdgeLengths = false; + /* section by 3 arbitrary points */ + this._section3PMode = false; // interactive picking active + this._section3PPicks = []; // Vector3[] — up to 3 picked points + this._section3PStepBy = false; // step-by-step visualisation toggle + this._section3PStep = 0; // current step (0=idle, 1..6=sub-steps) + this._section3PData = null; // computed result {normal,D,polygon,area,typeName} + this.onUpdate = null; this._buildGrid(); @@ -231,6 +241,11 @@ class StereoSim { this._deriveMode = null; this._derivePicks = []; this._clearGroup(this._derivedGroup); + this._section3PPicks = []; + this._section3PData = null; + this._section3PMode = false; + this._section3PStep = 0; + this._clearGroup(this._section3PGroup); this._buildFigure(); this._notify(); } @@ -466,6 +481,38 @@ class StereoSim { this._buildFigure(); } + /* ── Section by 3 arbitrary points ── */ + toggleSection3P(on) { + this._section3PMode = on; + // turn off all other interactive modes + this._pointMode = false; + this._connectMode = false; + this._measureMode = false; + this._angleMode = null; + this._markMode = null; + this._deriveMode = null; + this._connectPicks = []; + this.renderer.domElement.style.cursor = on ? 'crosshair' : 'grab'; + } + + clearSection3P() { + this._section3PPicks = []; + this._section3PData = null; + this._section3PStep = 0; + this._clearGroup(this._section3PGroup); + this._notify(); + } + + toggleSection3PStepBy(on) { + this._section3PStepBy = on; + // re-render if data already exists + if (this._section3PData) this._drawSection3P(); + } + + getSection3PInfo() { + return this._section3PData; + } + getFormulas() { const p = this.params; const PI = Math.PI; @@ -1734,6 +1781,213 @@ class StereoSim { } } + /* ════════════════ SECTION THROUGH 3 POINTS ════════════════ */ + + _onSection3PClick(e) { + if (!this._section3PMode) return; + if (this._section3PPicks.length >= 3) return; // already have 3 — need reset first + + const { mx, my } = this._screenCoords(e); + + // Pick nearest point: prefer vertex snap, then edge snap + let bestDist = 0.09; + let bestPos = null; + + for (const v of this._vertices) { + const proj = v.pos.clone().project(this.camera); + const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); + if (d < bestDist) { bestDist = d; bestPos = v.pos.clone(); } + } + + // Also check custom points if placed + for (const cp of this._customPoints) { + const proj = cp.pos.clone().project(this.camera); + const d = Math.sqrt((mx - proj.x) ** 2 + (my - proj.y) ** 2); + if (d < bestDist) { bestDist = d; bestPos = cp.pos.clone(); } + } + + // Edge snap (pick point on edge) + for (const edge of this._edges) { + const p1 = edge.from.clone().project(this.camera); + const p2 = edge.to.clone().project(this.camera); + const dx = p2.x - p1.x, dy = p2.y - p1.y; + const lenSq = dx * dx + dy * dy; + if (lenSq < 1e-9) continue; + let t = ((mx - p1.x) * dx + (my - p1.y) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const px = p1.x + t * dx, py = p1.y + t * dy; + const d = Math.sqrt((mx - px) ** 2 + (my - py) ** 2); + if (d < bestDist) { + bestDist = d; + bestPos = new THREE.Vector3().lerpVectors(edge.from, edge.to, t); + } + } + + if (!bestPos) return; + + // Avoid duplicate picks (too close) + for (const p of this._section3PPicks) { + if (p.distanceTo(bestPos) < 0.08) return; + } + + this._section3PPicks.push(bestPos); + this._drawSection3P(); + + if (this._section3PPicks.length === 3) { + this._computeSection3P(); + this._drawSection3P(); + this._notify(); + } + } + + _computeSection3P() { + const pts = this._section3PPicks; + if (pts.length < 3) { this._section3PData = null; return; } + + const [P1, P2, P3] = pts; + const v1 = new THREE.Vector3().subVectors(P2, P1); + const v2 = new THREE.Vector3().subVectors(P3, P1); + const normal = new THREE.Vector3().crossVectors(v1, v2); + if (normal.length() < 1e-9) { this._section3PData = null; return; } + normal.normalize(); + const D = -normal.dot(P1); + + // Intersect the plane with all edges of the solid + const polygon = this._sliceByNormal(normal, P1); + + if (polygon.length < 3) { this._section3PData = null; return; } + + const area = this._polygonArea(polygon); + const n = polygon.length; + const typeNames = { 3: 'треугольник', 4: 'четырёхугольник', 5: 'пятиугольник', 6: 'шестиугольник' }; + const typeName = typeNames[n] || `${n}-угольник`; + + this._section3PData = { normal, D, polygon, area, typeName, P1, P2, P3 }; + } + + _drawSection3P() { + this._clearGroup(this._section3PGroup); + const picks = this._section3PPicks; + const data = this._section3PData; + + // Draw picked points as spheres (yellow accent) + const PICK_COLOR = 0xFFD166; + const PLANE_COLOR = 0xEF476F; + const SECT_COLOR = 0x7BF5A4; + + picks.forEach((p, i) => { + const sGeo = new THREE.SphereGeometry(0.13, 10, 10); + const sMat = new THREE.MeshBasicMaterial({ color: PICK_COLOR }); + const s = new THREE.Mesh(sGeo, sMat); + s.position.copy(p); + this._section3PGroup.add(s); + + // Number label + const lbl = this._makeTextSprite(String(i + 1), '#FFD166', 42); + lbl.position.copy(p).add(new THREE.Vector3(0.25, 0.25, 0)); + lbl.scale.set(0.7, 0.28, 1); + this._section3PGroup.add(lbl); + }); + + // Draw line from P1 to P2 after 2nd pick + if (picks.length >= 2) { + const lg1 = new THREE.BufferGeometry().setFromPoints([picks[0], picks[1]]); + this._section3PGroup.add(new THREE.Line(lg1, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true }))); + } + if (picks.length >= 3) { + const lg2 = new THREE.BufferGeometry().setFromPoints([picks[1], picks[2]]); + this._section3PGroup.add(new THREE.Line(lg2, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.7, transparent: true }))); + const lg3 = new THREE.BufferGeometry().setFromPoints([picks[2], picks[0]]); + this._section3PGroup.add(new THREE.Line(lg3, new THREE.LineBasicMaterial({ color: PICK_COLOR, opacity: 0.5, transparent: true }))); + } + + if (!data || picks.length < 3) return; + + // Semi-transparent plane quad (large enough to show context) + const { normal, D, polygon } = data; + // Build a visible plane chip — use bounding box of polygon centroid + spread + const c = new THREE.Vector3(); + polygon.forEach(p => c.add(p)); + c.divideScalar(polygon.length); + // Local basis on plane + const u = new THREE.Vector3().subVectors(polygon[0], c).normalize(); + const v = new THREE.Vector3().crossVectors(normal, u).normalize(); + const spread = Math.max(...polygon.map(p => c.distanceTo(p))) * 1.5; + const planeVerts = [ + c.clone().addScaledVector(u, -spread).addScaledVector(v, -spread), + c.clone().addScaledVector(u, spread).addScaledVector(v, -spread), + c.clone().addScaledVector(u, spread).addScaledVector(v, spread), + c.clone().addScaledVector(u, -spread).addScaledVector(v, spread), + ]; + const planePositions = []; + [[0,1,2],[0,2,3]].forEach(tri => tri.forEach(i => { + const pv = planeVerts[i]; + planePositions.push(pv.x, pv.y, pv.z); + })); + const planeGeo = new THREE.BufferGeometry(); + planeGeo.setAttribute('position', new THREE.Float32BufferAttribute(planePositions, 3)); + const planeMat = new THREE.MeshBasicMaterial({ color: PLANE_COLOR, transparent: true, opacity: 0.08, side: THREE.DoubleSide }); + this._section3PGroup.add(new THREE.Mesh(planeGeo, planeMat)); + + // Cross-section polygon fill + const sectPositions = []; + const sectIndices = []; + polygon.forEach(p => sectPositions.push(p.x, p.y, p.z)); + for (let i = 1; i < polygon.length - 1; i++) sectIndices.push(0, i, i + 1); + const sectGeo = new THREE.BufferGeometry(); + sectGeo.setAttribute('position', new THREE.Float32BufferAttribute(sectPositions, 3)); + sectGeo.setIndex(sectIndices); + sectGeo.computeVertexNormals(); + const sectMat = new THREE.MeshBasicMaterial({ color: SECT_COLOR, transparent: true, opacity: 0.45, side: THREE.DoubleSide }); + this._section3PGroup.add(new THREE.Mesh(sectGeo, sectMat)); + + // Polygon outline (slightly offset along normal for visibility) + const outlinePts = [...polygon, polygon[0]].map(p => + p.clone().addScaledVector(normal, 0.012) + ); + const outlineGeo = new THREE.BufferGeometry().setFromPoints(outlinePts); + const outlineMat = new THREE.LineBasicMaterial({ color: SECT_COLOR, linewidth: 2 }); + this._section3PGroup.add(new THREE.Line(outlineGeo, outlineMat)); + + // Vertex markers on section polygon + polygon.forEach(p => { + const sg = new THREE.SphereGeometry(0.07, 8, 8); + const sm = new THREE.MeshBasicMaterial({ color: SECT_COLOR }); + const s = new THREE.Mesh(sg, sm); + s.position.copy(p); + this._section3PGroup.add(s); + }); + + // Step-by-step highlight (если включён пошаговый режим) + if (this._section3PStepBy && this._section3PStep > 0) { + this._drawSection3PStep(data); + } + } + + _drawSection3PStep(data) { + // Extra step-by-step highlight objects added to _section3PGroup + const step = this._section3PStep; + const picks = this._section3PPicks; + const HILITE = 0xFFFFA0; + + const flash = (pos) => { + const sg = new THREE.SphereGeometry(0.22, 10, 10); + const sm = new THREE.MeshBasicMaterial({ color: HILITE, transparent: true, opacity: 0.7 }); + const s = new THREE.Mesh(sg, sm); + s.position.copy(pos); + this._section3PGroup.add(s); + }; + const flashLine = (a, b) => { + const lg = new THREE.BufferGeometry().setFromPoints([a, b]); + this._section3PGroup.add(new THREE.Line(lg, new THREE.LineBasicMaterial({ color: HILITE, linewidth: 3 }))); + }; + + if (step >= 1) flash(picks[0]); + if (step >= 2) { flash(picks[1]); flashLine(picks[0], picks[1]); } + if (step >= 3) { flash(picks[2]); flashLine(picks[1], picks[2]); flashLine(picks[2], picks[0]); } + // steps 4-6 handled by full plane + section already drawn above + } + /* ════════════════ MEASUREMENT MODE ════════════════ */ _onMeasureClick(e) { @@ -3190,7 +3444,8 @@ class StereoSim { ['stereo-measure-btn','stereo-point-btn','stereo-connect-btn', 'stereo-angle-edge-btn','stereo-angle-lp-btn','stereo-angle-dih-btn','stereo-angle-pp-btn','stereo-angle-skew-btn', 'stereo-mark-tick-btn','stereo-mark-par-btn', - 'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn'].forEach(id => { + 'stereo-derive-mid-btn','stereo-derive-fc-btn','stereo-derive-alt-btn','stereo-derive-cen-btn', + 'stereo-sect3p-btn'].forEach(id => { document.getElementById(id)?.classList.remove('active'); }); if (stereoSim) { @@ -3200,6 +3455,7 @@ class StereoSim { stereoSim.setAngleMode(null); stereoSim.setMarkMode(null); stereoSim.setDeriveMode(null); + stereoSim.toggleSection3P(false); } const hint = document.getElementById('angle-hint'); if (hint) hint.textContent = ''; @@ -3328,6 +3584,69 @@ class StereoSim { _stereoUpdatePointsInfo(); } + /* ── Section through 3 points UI ── */ + function stereoSection3P(btn) { + const on = !btn.classList.contains('active'); + _stereoDeactivateTools(); + btn.classList.toggle('active', on); + if (stereoSim) stereoSim.toggleSection3P(on); + const hint = document.getElementById('sect3p-hint'); + if (hint) hint.textContent = on ? 'Кликните 3 точки на рёбрах или вершинах' : ''; + if (on) _stereoUpdateSection3PPanel(); + } + + function stereoSection3PClear() { + if (stereoSim) stereoSim.clearSection3P(); + _stereoUpdateSection3PPanel(); + } + + function stereoSection3PStepBy(toggle) { + const on = !toggle.classList.contains('on'); + toggle.classList.toggle('on', on); + if (stereoSim) stereoSim.toggleSection3PStepBy(on); + } + + function stereoSection3PNextStep() { + if (!stereoSim) return; + const max = stereoSim._section3PData ? 6 : stereoSim._section3PPicks.length; + stereoSim._section3PStep = Math.min(stereoSim._section3PStep + 1, max); + stereoSim._drawSection3P(); + } + + function stereoSection3PPrevStep() { + if (!stereoSim) return; + stereoSim._section3PStep = Math.max(0, stereoSim._section3PStep - 1); + stereoSim._drawSection3P(); + } + + function _stereoUpdateSection3PPanel() { + const panel = document.getElementById('sect3p-info'); + if (!panel) return; + if (!stereoSim) { panel.innerHTML = ''; return; } + const data = stereoSim.getSection3PInfo(); + const picks = stereoSim._section3PPicks; + if (!data && picks.length === 0) { panel.innerHTML = ''; return; } + + const r = v => Math.round(v * 100) / 100; + const fmtV = v => `(${r(v.x)}, ${r(v.y)}, ${r(v.z)})`; + const lines = []; + + picks.forEach((p, i) => lines.push(`
P${i+1} = ${fmtV(p)}
`)); + + if (data) { + const { normal: n, D, typeName, area } = data; + const A = r(n.x), B = r(n.y), C = r(n.z), Dv = r(D); + const eq = `${A}x + ${B}y + ${C}z ${Dv >= 0 ? '+' : ''}${Dv} = 0`; + lines.push(`
Плоскость: ${eq}
`); + lines.push(`
Сечение: ${typeName}
`); + if (area > 0) lines.push(`
S = ${r(area)}
`); + } else if (picks.length < 3) { + lines.push(`
Выбрано точек: ${picks.length}/3
`); + } + + panel.innerHTML = lines.join(''); + } + function stereoInscribed(btn) { const on = !btn.classList.contains('active'); btn.classList.toggle('active', on); @@ -3380,6 +3699,9 @@ class StereoSim { // Points info _stereoUpdatePointsInfo(info); + + // Section-3P panel + _stereoUpdateSection3PPanel(); } function _stereoUpdatePointsInfo(info) { diff --git a/frontend/js/labs/waves.js b/frontend/js/labs/waves.js index 1e597e0..56deb4d 100644 --- a/frontend/js/labs/waves.js +++ b/frontend/js/labs/waves.js @@ -1,7 +1,8 @@ 'use strict'; /* ═══════════════════════════════════════════ - WavesSim v2 — Волны и звук + WavesSim v3 — Волны и звук Modes: transverse | longitudinal | superposition | standing + doppler | beats | spectrum ─────────────────────────────────────────── */ class WavesSim { static BG = '#0D0D1A'; @@ -29,6 +30,25 @@ class WavesSim { this._n = 1; this._speed = 2.0; + /* doppler state */ + this._dopSrcX = 0; this._dopSrcY = 0; + this._dopObsX = 0; this._dopObsY = 0; + this._dopRings = []; /* [{x,y,r,age}] */ + this._dopDrag = null; /* 'src'|'obs'|null */ + this._dopVs = 0.35; /* source speed, px/s as fraction of c_px */ + this._dopDir = 1; /* +1 right, -1 left */ + this._dopSrcVelX = 0; + this._dopSrcVelY = 0; + this._dopLastEmit = 0; + + /* beats state */ + this._beatsF1 = 440; + this._beatsF2 = 444; + + /* spectrum state */ + this._specComponents = []; /* [{f, A}] */ + this._specNewF = 5; /* Hz of component to add (slider) */ + this._resizeObs = null; this.onUpdate = null; } @@ -55,6 +75,9 @@ class WavesSim { this._mode = mode; this._t = 0; this._last = null; + if (mode === 'doppler') this._dopInit(); + if (mode === 'spectrum' && !this._specComponents.length) + this._specComponents = [{ f: 5, A: 60 }, { f: 10, A: 30 }]; this.draw(); this._emit(); } @@ -63,15 +86,20 @@ class WavesSim { return { A1: this._A1, f1: this._f1, phi1: this._phi1, A2: this._A2, f2: this._f2, phi2: this._phi2, n: this._n, speed: this._speed, mode: this._mode }; } - setParams({ A1, f1, phi1, A2, f2, phi2, n, speed } = {}) { - if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1)); - if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1)); - if (phi1 !== undefined) this._phi1 = +phi1; - if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2)); - if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2)); - if (phi2 !== undefined) this._phi2 = +phi2; - if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n))); - if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed)); + setParams({ A1, f1, phi1, A2, f2, phi2, n, speed, + dopVs, beatsF1, beatsF2, specNewF } = {}) { + if (A1 !== undefined) this._A1 = Math.max(5, Math.min(90, +A1)); + if (f1 !== undefined) this._f1 = Math.max(0.3, Math.min(4, +f1)); + if (phi1 !== undefined) this._phi1 = +phi1; + if (A2 !== undefined) this._A2 = Math.max(5, Math.min(90, +A2)); + if (f2 !== undefined) this._f2 = Math.max(0.3, Math.min(4, +f2)); + if (phi2 !== undefined) this._phi2 = +phi2; + if (n !== undefined) this._n = Math.max(1, Math.min(5, Math.round(+n))); + if (speed !== undefined) this._speed = Math.max(0.3, Math.min(5, +speed)); + if (dopVs !== undefined) this._dopVs = Math.max(0, Math.min(1.8, +dopVs)); + if (beatsF1 !== undefined) this._beatsF1 = Math.max(1, Math.min(1000, +beatsF1)); + if (beatsF2 !== undefined) this._beatsF2 = Math.max(1, Math.min(1000, +beatsF2)); + if (specNewF !== undefined) this._specNewF = Math.max(1, Math.min(50, +specNewF)); this.draw(); this._emit(); } @@ -103,8 +131,11 @@ class WavesSim { _tick(ts) { if (!this._paused) { - if (this._last !== null) - this._t += Math.min((ts - this._last) / 1000, 0.05) * this._speed; + if (this._last !== null) { + const dt = Math.min((ts - this._last) / 1000, 0.05) * this._speed; + this._t += dt; + if (this._mode === 'doppler') this._dopStep(dt); + } this._last = ts; this._raf = requestAnimationFrame(t => this._tick(t)); } else { @@ -126,7 +157,10 @@ class WavesSim { if (this._mode === 'transverse') this._transvDraw(ctx, W, H); else if (this._mode === 'longitudinal') this._longDraw(ctx, W, H); else if (this._mode === 'superposition') this._superDraw(ctx, W, H); - else this._standDraw(ctx, W, H); + else if (this._mode === 'standing') this._standDraw(ctx, W, H); + else if (this._mode === 'doppler') this._dopplerDraw(ctx, W, H); + else if (this._mode === 'beats') this._beatsDraw(ctx, W, H); + else if (this._mode === 'spectrum') this._spectrumDraw(ctx, W, H); } /* ══════════════════════════════════════ @@ -410,6 +444,363 @@ class WavesSim { ctx.fillText('n = ' + n + ' \u03bb = 2L/' + n, PL, PT - 14); } + /* ══════════════════════════════════════ + ЭФФЕКТ ДОПЛЕРА + ══════════════════════════════════════ */ + + _dopInit() { + const W = this._W || 600, H = this._H || 400; + this._dopSrcX = W * 0.3; this._dopSrcY = H * 0.5; + this._dopObsX = W * 0.75; this._dopObsY = H * 0.5; + this._dopRings = []; + this._dopLastEmit = 0; + this._dopDir = 1; + } + + _dopStep(dt) { + const W = this._W || 600, H = this._H || 400; + /* speed in px/s: c_px ~= W*0.55 so Mach 1 is full screen width */ + const c_px = W * 0.55; + const vsPx = this._dopVs * c_px; + + /* move source horizontally, bounce at margins */ + if (!this._dopDrag || this._dopDrag !== 'src') { + this._dopSrcX += this._dopDir * vsPx * dt; + if (this._dopSrcX > W - 30) { this._dopSrcX = W - 30; this._dopDir = -1; } + if (this._dopSrcX < 30) { this._dopSrcX = 30; this._dopDir = 1; } + } + this._dopSrcVelX = this._dopDir * vsPx; + this._dopSrcVelY = 0; + + /* emit rings at source frequency f0 = _f1 */ + const f0 = Math.max(0.5, this._f1); + this._dopLastEmit += dt; + const emitInterval = 1 / f0; + while (this._dopLastEmit >= emitInterval) { + this._dopLastEmit -= emitInterval; + this._dopRings.push({ x: this._dopSrcX, y: this._dopSrcY, r: 0, age: 0 }); + } + + /* expand rings at c_px */ + const maxR = Math.sqrt(W * W + H * H); + this._dopRings = this._dopRings.filter(ring => { + ring.r += c_px * dt; + ring.age += dt; + return ring.r < maxR; + }); + } + + _dopplerDraw(ctx, W, H) { + if (!this._dopSrcX) this._dopInit(); + + const c_px = W * 0.55; + const vs = this._dopVs * c_px; /* px/s */ + const f0 = Math.max(0.5, this._f1); + + /* observed frequency (source moving toward/away observer) */ + const dx = this._dopObsX - this._dopSrcX; + const dy = this._dopObsY - this._dopSrcY; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const cosAngle = dx / dist; + /* projection of source velocity onto source→observer */ + const vsProj = this._dopDir * vs * cosAngle; /* +: toward obs */ + const fObs = f0 * c_px / Math.max(1, c_px - vsProj); + const mach = vs / c_px; + + /* draw rings */ + const ringAlpha = 0.55; + ctx.save(); + ctx.strokeStyle = WavesSim.C; + ctx.lineWidth = 1.5; + this._dopRings.forEach(ring => { + const a = Math.max(0, ringAlpha * (1 - ring.age * f0 * 0.5)); + if (a < 0.02) return; + ctx.globalAlpha = a; + ctx.beginPath(); + ctx.arc(ring.x, ring.y, ring.r, 0, Math.PI * 2); + ctx.stroke(); + }); + ctx.restore(); + + /* Mach cone if vs >= c_px */ + if (mach >= 1.0) { + const sinTheta = Math.min(1, c_px / vs); + const theta = Math.asin(sinTheta); + const coneLen = W * 0.9; + const sx = this._dopSrcX, sy = this._dopSrcY; + const dir = this._dopDir; + ctx.save(); + ctx.globalAlpha = 0.35; + ctx.fillStyle = WavesSim.P; + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx - dir * coneLen * Math.cos(theta), + sy - coneLen * Math.sin(theta)); + ctx.lineTo(sx - dir * coneLen * Math.cos(theta), + sy + coneLen * Math.sin(theta)); + ctx.closePath(); ctx.fill(); + ctx.restore(); + ctx.save(); + ctx.strokeStyle = WavesSim.P; ctx.lineWidth = 1.5; ctx.globalAlpha = 0.7; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.moveTo(sx, sy); + ctx.lineTo(sx - dir * coneLen * Math.cos(theta), + sy - coneLen * Math.sin(theta)); + ctx.moveTo(sx, sy); + ctx.lineTo(sx - dir * coneLen * Math.cos(theta), + sy + coneLen * Math.sin(theta)); + ctx.stroke(); + ctx.restore(); + } + + /* source dot */ + ctx.save(); + ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 18; + ctx.fillStyle = WavesSim.G; + ctx.beginPath(); ctx.arc(this._dopSrcX, this._dopSrcY, 9, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = WavesSim.BG; + ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('S', this._dopSrcX, this._dopSrcY); + ctx.textBaseline = 'alphabetic'; + + /* observer dot */ + ctx.save(); + ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 14; + ctx.fillStyle = WavesSim.P; + ctx.beginPath(); ctx.arc(this._dopObsX, this._dopObsY, 7, 0, Math.PI * 2); ctx.fill(); + ctx.restore(); + ctx.fillStyle = WavesSim.BG; + ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText('O', this._dopObsX, this._dopObsY); + ctx.textBaseline = 'alphabetic'; + + /* HUD */ + const hudX = 14, hudY = 20; + ctx.fillStyle = 'rgba(13,13,26,0.72)'; + ctx.beginPath(); ctx.roundRect(hudX, hudY, 178, 72, 8); ctx.fill(); + ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left'; + const rows = [ + { c: WavesSim.G, t: 'f₀ = ' + f0.toFixed(1) + ' Гц' }, + { c: WavesSim.C, t: 'fᵒᵇˢ = ' + fObs.toFixed(1) + ' Гц' }, + { c: WavesSim.P, t: 'Mach = ' + mach.toFixed(2) + (mach >= 1 ? ' [удар. волна]' : '') }, + ]; + rows.forEach((r, i) => { + ctx.fillStyle = r.c; + ctx.fillText(r.t, hudX + 10, hudY + 18 + i * 18); + }); + + /* drag hint */ + ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; + ctx.fillText('Перетащи S (источник) или O (наблюдатель)', W / 2, H - 10); + } + + /* ══════════════════════════════════════ + БИЕНИЯ + ══════════════════════════════════════ */ + + _beatsDraw(ctx, W, H) { + const PL = 48, PR = 20, PT = 60, PB = 40; + const cw = W - PL - PR; + const ch = H - PT - PB; + const cy = PT + ch / 2; + + this._grid(ctx, PL, PR, PT, PB, W, H); + this._axisLine(ctx, PL, PR, PT, PB, W, H, cy); + + const f1 = this._beatsF1; + const f2 = this._beatsF2; + const fBeat = Math.abs(f1 - f2); + const fAvg = (f1 + f2) / 2; + const TBeat = fBeat > 0 ? 1 / fBeat : Infinity; + + /* draw time window that spans ~3 beat periods (or 0.1s if no beat) */ + const winS = TBeat < Infinity ? Math.min(TBeat * 3, 2) : 0.12; + const tOff = this._t % (winS > 0 ? winS : 1); /* scroll slowly */ + const A = Math.max(4, Math.min(ch / 2 - 4, 60)); + + /* sum waveform */ + ctx.save(); + ctx.shadowColor = WavesSim.P; ctx.shadowBlur = 12; + ctx.strokeStyle = WavesSim.P; ctx.lineWidth = 2; + ctx.beginPath(); + for (let px = 0; px <= cw; px++) { + const t_s = (px / cw) * winS + tOff; + const y = A * Math.cos(2 * Math.PI * f1 * t_s) + + A * Math.cos(2 * Math.PI * f2 * t_s); + const py = cy - y / 2; /* /2 because sum can reach 2A */ + px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py); + } + ctx.stroke(); ctx.restore(); + + /* envelope */ + ctx.save(); + ctx.strokeStyle = WavesSim.G; ctx.lineWidth = 1.4; ctx.globalAlpha = 0.6; + ctx.setLineDash([6, 4]); + for (const sign of [1, -1]) { + ctx.beginPath(); + for (let px = 0; px <= cw; px++) { + const t_s = (px / cw) * winS + tOff; + const env = 2 * A * Math.abs(Math.cos(Math.PI * fBeat * t_s)); + const py = cy - sign * env / 2; + px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py); + } + ctx.stroke(); + } + ctx.restore(); + + /* individual waves (dimmed) */ + const drawSingle = (f, color) => { + ctx.save(); + ctx.globalAlpha = 0.3; ctx.strokeStyle = color; ctx.lineWidth = 1; + ctx.beginPath(); + for (let px = 0; px <= cw; px++) { + const t_s = (px / cw) * winS + tOff; + const py = cy - A * Math.cos(2 * Math.PI * f * t_s); + px === 0 ? ctx.moveTo(PL + px, py) : ctx.lineTo(PL + px, py); + } + ctx.stroke(); ctx.restore(); + }; + drawSingle(f1, WavesSim.V); + drawSingle(f2, WavesSim.C); + + /* HUD */ + ctx.fillStyle = 'rgba(13,13,26,0.72)'; + ctx.beginPath(); ctx.roundRect(PL + 6, PT - 52, 220, 48, 8); ctx.fill(); + ctx.font = "600 10px 'Manrope',sans-serif"; ctx.textAlign = 'left'; + const hudRows = [ + { c: WavesSim.V, t: 'f₁ = ' + f1.toFixed(1) + ' Гц' }, + { c: WavesSim.C, t: 'f₂ = ' + f2.toFixed(1) + ' Гц' }, + { c: WavesSim.G, t: 'fбиет = ' + fBeat.toFixed(2) + ' Гц Tбиет = ' + (TBeat < Infinity ? TBeat.toFixed(3) : '∞') + ' с' }, + ]; + hudRows.forEach((r, i) => { + ctx.fillStyle = r.c; + ctx.fillText(r.t, PL + 16, PT - 36 + i * 16); + }); + + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.font = WavesSim.FONT; ctx.textAlign = 'left'; + ctx.fillText('Биения: fбиет = |f₁ − f₂|', PL, H - 8); + } + + /* ══════════════════════════════════════ + СПЕКТР (ДПФ) + ══════════════════════════════════════ */ + + _dft(signal) { + /* Real-valued DFT, returns magnitude array of length N/2 */ + const N = signal.length; + const half = Math.floor(N / 2); + const mag = new Float32Array(half); + for (let k = 0; k < half; k++) { + let re = 0, im = 0; + const angle = (2 * Math.PI * k) / N; + for (let n = 0; n < N; n++) { + re += signal[n] * Math.cos(angle * n); + im -= signal[n] * Math.sin(angle * n); + } + mag[k] = Math.sqrt(re * re + im * im) / N; + } + return mag; + } + + _spectrumDraw(ctx, W, H) { + const PL = 48, PR = 20, PT = 40, PB = 60; + const cw = W - PL - PR; + const ch = H - PT - PB; + + /* build signal from components */ + const N = 256; + const fs = 100; /* sample rate Hz */ + const signal = new Float32Array(N); + const comps = this._specComponents; + for (let n = 0; n < N; n++) { + let val = 0; + for (const c of comps) val += (c.A / 90) * Math.cos(2 * Math.PI * c.f * n / fs + this._t); + signal[n] = val; + } + + /* DFT */ + const mag = this._dft(signal); + const half = mag.length; + const df = fs / N; /* Hz per bin */ + const maxF = fs / 2; /* Nyquist */ + + /* find max for normalisation */ + let maxMag = 0; + for (let k = 0; k < half; k++) if (mag[k] > maxMag) maxMag = mag[k]; + if (maxMag < 1e-9) maxMag = 1; + + /* axes */ + this._axisLine(ctx, PL, PR, PT, PB, W, H, PT + ch); + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; ctx.lineWidth = 1; + ctx.beginPath(); + for (let gx = PL; gx <= PL + cw; gx += 40) { ctx.moveTo(gx, PT); ctx.lineTo(gx, PT + ch); } + for (let gy = PT; gy <= PT + ch; gy += 28) { ctx.moveTo(PL, gy); ctx.lineTo(PL + cw, gy); } + ctx.stroke(); + + /* frequency axis labels */ + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = "500 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; + const nLabels = Math.min(10, Math.floor(cw / 40)); + for (let i = 0; i <= nLabels; i++) { + const f = (maxF * i) / nLabels; + const x = PL + cw * i / nLabels; + ctx.fillText(f.toFixed(0) + 'Hz', x, PT + ch + 14); + } + ctx.fillText('Частота', PL + cw / 2, H - 4); + ctx.save(); ctx.translate(PL - 32, PT + ch / 2); ctx.rotate(-Math.PI / 2); + ctx.fillText('Амплитуда', 0, 0); ctx.restore(); + + /* bars */ + const barW = Math.max(1, cw / half - 1); + for (let k = 0; k < half; k++) { + const norm = mag[k] / maxMag; + const bH = norm * ch; + const bx = PL + k * (cw / half); + const color = norm > 0.5 ? WavesSim.G : WavesSim.V; + ctx.save(); + if (norm > 0.5) { ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 10; } + ctx.fillStyle = color; + ctx.globalAlpha = 0.3 + norm * 0.7; + ctx.fillRect(bx, PT + ch - bH, barW, bH); + ctx.restore(); + } + + /* label peaks — bins where this bin's magnitude > neighbors & > 5% */ + const fmtF = f => f.toFixed(1) + 'Hz'; + ctx.font = "700 9px 'Manrope',sans-serif"; ctx.textAlign = 'center'; + for (let k = 1; k < half - 1; k++) { + if (mag[k] > mag[k - 1] && mag[k] > mag[k + 1] && mag[k] / maxMag > 0.05) { + const bx = PL + k * (cw / half) + barW / 2; + const bH = (mag[k] / maxMag) * ch; + ctx.save(); + ctx.shadowColor = WavesSim.G; ctx.shadowBlur = 6; + ctx.fillStyle = WavesSim.G; + ctx.fillText(fmtF(k * df), bx, PT + ch - bH - 5); + ctx.restore(); + } + } + + /* components list */ + const listX = PL + 6, listY = PT + 6; + ctx.fillStyle = 'rgba(13,13,26,0.7)'; + ctx.beginPath(); + ctx.roundRect(listX, listY, 140, Math.min(comps.length * 16 + 8, ch - 12), 6); + ctx.fill(); + ctx.font = "600 9px 'Manrope',sans-serif"; ctx.textAlign = 'left'; + comps.forEach((c, i) => { + ctx.fillStyle = i % 2 === 0 ? WavesSim.V : WavesSim.C; + ctx.fillText('f=' + c.f.toFixed(0) + 'Hz A=' + c.A, listX + 8, listY + 14 + i * 16); + }); + + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.font = WavesSim.FONT; ctx.textAlign = 'left'; + ctx.fillText('ДПФ: N=' + N + ', fs=' + fs + 'Hz', PL, PT - 12); + } + /* ══════════════════════════════════════ ВСПОМОГАТЕЛЬНЫЕ ══════════════════════════════════════ */ @@ -452,6 +843,56 @@ class WavesSim { ctx.textAlign = 'left'; ctx.fillText('x', W - PR + 8, H - PB + 4); } + /* Spectrum: add a component at current _specNewF */ + specAddComponent() { + const f = this._specNewF; + const A = 60; + if (this._specComponents.length < 12) + this._specComponents.push({ f, A }); + this.draw(); + } + + /* Spectrum: clear all components */ + specClear() { + this._specComponents = []; + this.draw(); + } + + /* Doppler: attach mouse/touch drag for source and observer */ + dopAttachDrag(canvas) { + const pos = e => { + const r = canvas.getBoundingClientRect(); + const src = e.touches ? e.touches[0] : e; + return { x: (src.clientX - r.left) * (this._W / r.width), + y: (src.clientY - r.top) * (this._H / r.height) }; + }; + const hitTest = p => { + const dS = Math.hypot(p.x - this._dopSrcX, p.y - this._dopSrcY); + const dO = Math.hypot(p.x - this._dopObsX, p.y - this._dopObsY); + if (dS < 18) return 'src'; + if (dO < 18) return 'obs'; + return null; + }; + const start = e => { + const p = pos(e); this._dopDrag = hitTest(p); + if (this._dopDrag) e.preventDefault(); + }; + const move = e => { + if (!this._dopDrag) return; + e.preventDefault(); + const p = pos(e); + if (this._dopDrag === 'src') { this._dopSrcX = p.x; this._dopSrcY = p.y; } + else { this._dopObsX = p.x; this._dopObsY = p.y; } + }; + const end = () => { this._dopDrag = null; }; + canvas.addEventListener('mousedown', start); + canvas.addEventListener('mousemove', move); + canvas.addEventListener('mouseup', end); + canvas.addEventListener('touchstart', start, { passive: false }); + canvas.addEventListener('touchmove', move, { passive: false }); + canvas.addEventListener('touchend', end); + } + _emit() { if (this.onUpdate) this.onUpdate(this.info()); } } @@ -478,9 +919,15 @@ class WavesSim { function wavesMode(mode, btn) { document.querySelectorAll('.wave-mode-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); - document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none'; - document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none'; - if (wavesSim) wavesSim.setMode(mode); + document.getElementById('waves-w2-section').style.display = mode === 'superposition' ? '' : 'none'; + document.getElementById('waves-n-section').style.display = mode === 'standing' ? '' : 'none'; + document.getElementById('waves-doppler-section').style.display = mode === 'doppler' ? '' : 'none'; + document.getElementById('waves-beats-section').style.display = mode === 'beats' ? '' : 'none'; + document.getElementById('waves-spectrum-section').style.display = mode === 'spectrum' ? '' : 'none'; + if (wavesSim) { + wavesSim.setMode(mode); + if (mode === 'doppler') wavesSim.dopAttachDrag(document.getElementById('waves-canvas')); + } } function wavesParam(name, val) { @@ -492,10 +939,22 @@ class WavesSim { if (name === 'A2') el('waves-A2-val', v); if (name === 'f2') el('waves-f2-val', v.toFixed(1) + ' Гц'); if (name === 'phi2') el('waves-phi2-val', v.toFixed(1)); - if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1)); + if (name === 'speed') el('waves-speed-val', '\u00d7' + v.toFixed(1)); + if (name === 'dopVs') el('waves-dopVs-val', v.toFixed(2) + 'c'); + if (name === 'beatsF1') el('waves-beatsF1-val', v.toFixed(0) + ' \u0413\u0446'); + if (name === 'beatsF2') el('waves-beatsF2-val', v.toFixed(0) + ' \u0413\u0446'); + if (name === 'specNewF') el('waves-specNewF-val', v.toFixed(0) + ' \u0413\u0446'); if (wavesSim) wavesSim.setParams({ [name]: v }); } + function wavesSpecAdd() { + if (wavesSim) wavesSim.specAddComponent(); + } + + function wavesSpecClear() { + if (wavesSim) wavesSim.specClear(); + } + function wavesN(n, btn) { document.querySelectorAll('.wave-n-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); diff --git a/frontend/lab.html b/frontend/lab.html index 344425c..cc74183 100644 --- a/frontend/lab.html +++ b/frontend/lab.html @@ -167,6 +167,7 @@ + @@ -183,6 +184,13 @@ +
+ + @@ -510,6 +518,21 @@ +
Поверхность Гаусса
+ + +
Пресеты E
@@ -583,6 +606,14 @@ +
ЭДС индукции
+ +
Пресеты B
@@ -591,6 +622,7 @@ +
@@ -624,6 +656,10 @@
Курсор |B|
+
Φₐ Гаусса
+
+
ЭДС ε
+
Клик — добавить  ·  ПКМ / 2×клик — удалить
@@ -647,6 +683,8 @@
|B| курсора
Сила Ампера
Поток Φ
+
Φₐ Гаусса
+
ЭДС ε
@@ -1104,6 +1142,7 @@ + @@ -1140,6 +1179,14 @@ +
+
+ Индуктивность L + 10 мГн +
+ +
+
Частота AC @@ -1159,6 +1206,7 @@ +
@@ -1170,6 +1218,10 @@
+
@@ -1991,6 +2043,22 @@
+ +
+
+
+ Молекулярное + +
+
+ Полное ионное + +
+
+ Сокращённое ионное + +
+
@@ -2708,6 +2776,9 @@ + + + @@ -2773,6 +2844,63 @@ + + + + + + + + +
Пресеты
@@ -3067,6 +3195,27 @@
+ +
Сечение через 3 точки
+
+ +
+
+ +
+
+
+
+ Пошагово +
+
+
+ + +
+
Формулы
@@ -3472,6 +3621,11 @@ Очистить + @@ -3486,6 +3640,19 @@ + + +
+
+ Задачник + +
+
+ +
+