feat(labs): wave 2 — depth features across 6 sims

Электрические цепи (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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-23 12:48:14 +03:00
parent 7f75c96acd
commit 8f30a8cef6
8 changed files with 2367 additions and 36 deletions
+222
View File
@@ -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;
}
+54 -1
View File
@@ -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(/<svg[^>]*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) : '<span style="opacity:.45">ионное уравнение недоступно</span>';
netLine.innerHTML = info.ionNet ? _stripSvg(info.ionNet) : '<span style="opacity:.45">сокращённое ионное недоступно</span>';
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 ── */
+234 -7
View File
@@ -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<loopN;i++) {
const cx=-totalW/2+loopR+i*loopR*2;
ctx.beginPath();
ctx.arc(cx, 0, loopR, Math.PI, 0, false);
ctx.stroke();
}
ctx.restore();
const L_mH = c.L_value ?? this.L_value ?? 10;
ctx.font='9px Manrope,sans-serif'; ctx.fillStyle='rgba(200,160,255,0.75)';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(`L=${L_mH} мГн`, mx-uy*16, my+ux*16-4);
ctx.textBaseline='alphabetic';
}
_drawDiode(ctx, c, p1, p2, mx, my, hasI) {
const dx=p2.x-p1.x, dy=p2.y-p1.y;
const len=Math.hypot(dx,dy);
@@ -845,6 +901,7 @@ class CircuitSim {
case 'switch': this._drawSwitch(ctx,c,p1,p2,mx,my,hasI); break;
case 'lamp': this._drawLamp(ctx,c,p1,p2,mx,my,hasI); break;
case 'capacitor': this._drawCapacitor(ctx,c,p1,p2,mx,my); break;
case 'inductor': this._drawInductor(ctx,c,p1,p2,mx,my,hasI); break;
case 'diode': this._drawDiode(ctx,c,p1,p2,mx,my,hasI); break;
case 'led': this._drawLED(ctx,c,p1,p2,mx,my,hasI); break;
case 'ammeter': this._drawAmmeter(ctx,c,p1,p2,mx,my); break;
@@ -939,10 +996,138 @@ class CircuitSim {
ctx.font='11px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.09)';
ctx.fillText('ПКМ — удалить · Dbl‑клик на ключе — переключить · Del — удалить выбранный', this.W/2, this.H/2+12);
ctx.font='10px Manrope,sans-serif'; ctx.fillStyle='rgba(255,255,255,0.07)';
ctx.fillText('Ctrl+Z / Ctrl+Y — Undo/Redo · W R B S L C D A V E — горячие клавиши', this.W/2, this.H/2+30);
ctx.fillText('Ctrl+Z / Ctrl+Y — Undo/Redo · W R B S L C I D A V E — горячие клавиши', this.W/2, this.H/2+30);
ctx.textBaseline='alphabetic';
}
/* ─── Power heatmap overlay ─────────────────────────────────────────────── */
_drawHeatmap(ctx) {
if (!this._heatmapOn || !this._solution?.solved) return;
// Compute power for each dissipating component
const dissipators = this.components.filter(c =>
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;
+429 -9
View File
@@ -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';
}
}
}
+461
View File
@@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="#4ADE80" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px"><polyline points="20 6 9 17 4 12"/></svg>';
} else if (state === 'locked') {
statusContent = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:10px;height:10px"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
}
item.innerHTML = `
<div class="geo-chall-head">
<div class="geo-chall-status">${statusContent}</div>
<div class="geo-chall-name">${ch.title}</div>
</div>
<div class="geo-chall-body">
<div class="geo-chall-desc">${ch.desc}</div>
<div class="geo-chall-actions">
<button class="geo-chall-btn geo-chall-btn-check" onclick="geoChallCheck(${idx})">Проверить</button>
<button class="geo-chall-btn geo-chall-btn-reset" onclick="geoChallSetup(${idx})" title="Начать заново">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="width:12px;height:12px"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.95"/></svg>
</button>
</div>
<div class="geo-chall-hint${_challAttempts[idx] >= 2 ? ' visible' : ''}" id="geo-chall-hint-${idx}">${ch.hint}</div>
<div class="geo-chall-feedback" id="geo-chall-fb-${idx}"></div>
</div>`;
// 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);
}
+324 -2
View File
@@ -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(`<div style="color:#FFD166">P${i+1} = ${fmtV(p)}</div>`));
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(`<div style="color:#EF476F;margin-top:4px">Плоскость: ${eq}</div>`);
lines.push(`<div style="color:#7BF5A4">Сечение: <b>${typeName}</b></div>`);
if (area > 0) lines.push(`<div style="color:#7BF5A4">S = ${r(area)}</div>`);
} else if (picks.length < 3) {
lines.push(`<div style="color:rgba(255,255,255,0.35)">Выбрано точек: ${picks.length}/3</div>`);
}
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) {
+476 -17
View File
@@ -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');
+167
View File
@@ -167,6 +167,7 @@
<button class="zoom-btn circ-top-btn" id="ctool-resistor" onclick="circTool('resistor',this)" title="Резистор (R)" style="font-size:.6rem;font-weight:800">R</button>
<button class="zoom-btn circ-top-btn" id="ctool-battery" onclick="circTool('battery',this)" title="Батарея (B)" style="font-size:.6rem;font-weight:800">U</button>
<button class="zoom-btn circ-top-btn" id="ctool-capacitor" onclick="circTool('capacitor',this)" title="Конденсатор (C)" style="font-size:.6rem;font-weight:800">C</button>
<button class="zoom-btn circ-top-btn" id="ctool-inductor" onclick="circTool('inductor',this)" title="Катушка индуктивности (I)" style="font-size:.65rem"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 12 Q4 8 6 12 Q8 8 10 12 Q12 8 14 12"/><line x1="14" y1="12" x2="22" y2="12"/><line x1="2" y1="12" x2="2" y2="12"/></svg></button>
<button class="zoom-btn circ-top-btn" id="ctool-diode" onclick="circTool('diode',this)" title="Диод (D)" style="font-size:.75rem"><svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>|</button>
<button class="zoom-btn circ-top-btn" id="ctool-led" onclick="circTool('led',this)" title="LED" style="font-size:.6rem;font-weight:800">LED</button>
<button class="zoom-btn circ-top-btn" id="ctool-ac" onclick="circTool('ac',this)" title="AC источник" style="font-size:.65rem;font-weight:800">AC</button>
@@ -183,6 +184,13 @@
<button class="zoom-btn" onclick="cirSim&&cirSim.preset('clear')" title="Очистить">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
</button>
<div style="width:1px;height:20px;background:rgba(255,255,255,0.15);margin:0 2px"></div>
<button class="zoom-btn" id="ctool-heat" onclick="circToggleHeat()" title="Тепловая карта мощности" style="font-size:.6rem;font-weight:700">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2C8 6 6 10 8 14c1 2 3 4 4 6"/><path d="M16 6c-2 3-3 6-1 9 1 1.5 1 3 0 4"/><path d="M8 6C6 9 5 12 7 15"/></svg>
</button>
<button class="zoom-btn" id="btn-osc-toggle" onclick="circToggleOsc()" title="Осциллограф U(t) / I(t)" style="font-size:.6rem;font-weight:700">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="4" width="20" height="16" rx="2"/><polyline points="6 16 8 10 11 14 13 8 16 16"/></svg>
</button>
</div>
<!-- reactions controls -->
@@ -510,6 +518,21 @@
</label>
</div>
<div class="gp-section-title" style="margin-bottom:6px">Поверхность Гаусса</div>
<label class="tri-layer-row" id="em-gauss-row" onclick="emGaussToggle(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:#34d399;box-shadow:0 0 5px #34d399"></span>
<span class="tri-layer-name">Поток Гаусса &#934;&#8336;</span>
<span class="tri-layer-hint" style="color:#34d399">&#934;=q/&#949;&#8320;</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
<div class="param-block" id="em-gauss-r-block" style="display:none;margin-bottom:10px">
<div class="param-header">
<span class="param-name">Радиус поверхности</span>
<span class="param-val" id="em-gaussR-val">70 пкс</span>
</div>
<input type="range" class="param-slider" id="sl-emGaussR" min="20" max="200" value="70" oninput="emGaussRChange()">
</div>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты E</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="emPresetE('dipole')">Диполь &#177;</button>
@@ -583,6 +606,14 @@
<span class="tri-toggle"></span>
</label>
<div class="gp-section-title" style="margin-bottom:6px">ЭДС индукции</div>
<label class="tri-layer-row" id="em-rod-row" onclick="emRodToggle(this)" style="margin-bottom:6px">
<span class="tri-dot" style="background:#f59e0b;box-shadow:0 0 5px #f59e0b"></span>
<span class="tri-layer-name">Движущийся проводник</span>
<span class="tri-layer-hint" style="color:#f59e0b">&#949;=&#8747;vBdl</span>
<span class="tri-toggle"><span style="display:block;width:12px;height:12px;border-radius:50%;background:#fff;margin:2px;margin-left:2px"></span></span>
</label>
<div class="gp-section-title" style="margin-bottom:6px">Пресеты B</div>
<div style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:10px">
<button class="proj-preset-chip" onclick="emPresetB('single')">Один провод</button>
@@ -591,6 +622,7 @@
<button class="proj-preset-chip" onclick="emPresetB('solenoid')">Соленоид</button>
<button class="proj-preset-chip" onclick="emPresetB('quadrupole')">Квадруполь</button>
<button class="proj-preset-chip" onclick="emPresetB('ring')">Кольцо</button>
<button class="proj-preset-chip" onclick="emPresetB('toroid')">Тороид</button>
</div>
</div><!-- /#em-ctrl-B -->
@@ -624,6 +656,10 @@
<div class="tri-stat-v" id="embar-curV" style="color:rgba(255,255,255,0.5)">&#8212;</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">Курсор |B|</div>
<div class="tri-stat-v" id="embar-curB" style="color:var(--cyan)">&#8212;</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">&#934;&#8336; Гаусса</div>
<div class="tri-stat-v" id="embar-gauss" style="color:#34d399">&#8212;</div>
<div style="font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)">ЭДС &#949;</div>
<div class="tri-stat-v" id="embar-rod" style="color:#f59e0b">&#8212;</div>
</div>
<div style="font-size:0.68rem;color:var(--text-3);text-align:center;line-height:1.6;margin-top:4px">
Клик &#8212; добавить &nbsp;&#183;&nbsp; ПКМ / 2&#215;клик &#8212; удалить<br>
@@ -647,6 +683,8 @@
<div class="pstat"><div class="pstat-label">|B| курсора</div><div class="pstat-val" id="embar-curB-bar" style="color:var(--cyan)">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Сила Ампера</div><div class="pstat-val" id="embar-ampere" style="color:#fbbf24">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">Поток &#934;</div><div class="pstat-val" id="embar-flux" style="color:#34d399">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">&#934;&#8336; Гаусса</div><div class="pstat-val" id="embar-gauss-bar" style="color:#34d399">&#8212;</div></div>
<div class="pstat"><div class="pstat-label">ЭДС &#949;</div><div class="pstat-val" id="embar-rod-bar" style="color:#f59e0b">&#8212;</div></div>
</div>
</div><!-- /#sim-emfield -->
@@ -1104,6 +1142,7 @@
<button class="proj-preset-chip circ-tool-btn" id="ptool-resistor" onclick="circTool('resistor',this)" data-tool="resistor">Резистор</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-battery" onclick="circTool('battery',this)" data-tool="battery">Батарея</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-capacitor" onclick="circTool('capacitor',this)" data-tool="capacitor">Конденсатор</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-inductor" onclick="circTool('inductor',this)" data-tool="inductor">Катушка L</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-diode" onclick="circTool('diode',this)" data-tool="diode">Диод</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-led" onclick="circTool('led',this)" data-tool="led">LED</button>
<button class="proj-preset-chip circ-tool-btn" id="ptool-ac" onclick="circTool('ac',this)" data-tool="ac">AC источник</button>
@@ -1140,6 +1179,14 @@
<input type="range" class="param-slider" id="sl-circC" min="10" max="1000" value="100" step="10" oninput="circCChange()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Индуктивность L</span>
<span class="param-val" id="circ-L-val">10 мГн</span>
</div>
<input type="range" class="param-slider" id="sl-circL" min="1" max="1000" value="10" step="1" oninput="circLChange()">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Частота AC</span>
@@ -1159,6 +1206,7 @@
<button class="proj-preset-chip" onclick="circPreset('led')">LED</button>
<button class="proj-preset-chip" onclick="circPreset('rc')">RC-цепь</button>
<button class="proj-preset-chip" onclick="circPreset('ac')">AC-цепь</button>
<button class="proj-preset-chip" onclick="circPreset('rlc')">RLC-резонанс</button>
</div>
<div style="margin-top:auto;font-size:0.68rem;color:var(--text-3);text-align:center;line-height:1.7;padding-top:4px">
@@ -1170,6 +1218,10 @@
<div class="proj-canvas-outer">
<canvas id="circuit-canvas" style="display:block;position:absolute;top:0;left:0;width:100%;height:100%;cursor:crosshair"></canvas>
<div id="osc-panel" style="display:none;position:absolute;bottom:8px;right:8px;z-index:10;background:rgba(6,6,22,0.95);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:6px">
<div style="font-size:0.7rem;color:rgba(255,255,255,0.45);margin-bottom:4px;text-align:center">Осциллограф</div>
<canvas id="osc-canvas" width="300" height="180" style="display:block;border-radius:4px"></canvas>
</div>
</div>
</div><!-- /.sim-body-wrap -->
@@ -1991,6 +2043,22 @@
</div>
<div class="proj-canvas-outer">
<canvas id="chemsandbox-canvas" style="display:block;position:absolute;top:0;left:0;width:100%;height:100%;cursor:default"></canvas>
<!-- equation overlay: shown when reaction fires -->
<div id="chemsand-eq-overlay" class="chemsand-eq-overlay">
<div id="chemsand-eq-type" class="chemsand-eq-type"></div>
<div class="chemsand-eq-row chemsand-eq-row--mol">
<span class="chemsand-eq-label">Молекулярное</span>
<span id="chemsand-eq-mol" class="chemsand-eq-text chemsand-eq-text--mol"></span>
</div>
<div class="chemsand-eq-row chemsand-eq-row--full">
<span class="chemsand-eq-label">Полное ионное</span>
<span id="chemsand-eq-full" class="chemsand-eq-text chemsand-eq-text--full"></span>
</div>
<div class="chemsand-eq-row chemsand-eq-row--net">
<span class="chemsand-eq-label">Сокращённое ионное</span>
<span id="chemsand-eq-net" class="chemsand-eq-text chemsand-eq-text--net"></span>
</div>
</div>
</div>
</div>
<!-- Stats bar -->
@@ -2708,6 +2776,9 @@
<button class="wave-mode-btn" onclick="wavesMode('longitudinal',this)">Продольная</button>
<button class="wave-mode-btn" onclick="wavesMode('superposition',this)">Суперпозиция</button>
<button class="wave-mode-btn" onclick="wavesMode('standing',this)">Стоячая</button>
<button class="wave-mode-btn" onclick="wavesMode('doppler',this)">Доплер</button>
<button class="wave-mode-btn" onclick="wavesMode('beats',this)">Биения</button>
<button class="wave-mode-btn" onclick="wavesMode('spectrum',this)" style="grid-column:span 2">Спектр (ДПФ)</button>
</div>
<!-- Wave 1 -->
@@ -2773,6 +2844,63 @@
</div>
</div>
<!-- Doppler controls -->
<div id="waves-doppler-section" style="display:none">
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Эффект Доплера</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Скорость источника v<sub>s</sub></span>
<span class="param-val" id="waves-dopVs-val" style="color:var(--gold)">0.35c</span>
</div>
<input type="range" id="sl-waves-dopVs" class="param-slider" min="0" max="1.8" step="0.05" value="0.35" oninput="wavesParam('dopVs',this.value)" style="accent-color:#FFD166">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Частота источника f₀</span>
<span class="param-val" id="waves-f1-dop-val" style="color:var(--violet)"></span>
</div>
<input type="range" id="sl-waves-f1-dop" class="param-slider" min="0.3" max="4" step="0.1" value="1.0" oninput="wavesParam('f1',this.value)">
</div>
<div class="pp-hint" style="margin-bottom:8px">S — источник (перетащи), O — наблюдатель (перетащи)</div>
</div>
<!-- Beats controls -->
<div id="waves-beats-section" style="display:none">
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Биения</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Частота f₁</span>
<span class="param-val" id="waves-beatsF1-val" style="color:var(--violet)">440 Гц</span>
</div>
<input type="range" id="sl-waves-beatsF1" class="param-slider" min="100" max="1000" step="1" value="440" oninput="wavesParam('beatsF1',this.value)">
</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Частота f₂</span>
<span class="param-val" id="waves-beatsF2-val" style="color:var(--cyan)">444 Гц</span>
</div>
<input type="range" id="sl-waves-beatsF2" class="param-slider" min="100" max="1000" step="1" value="444" oninput="wavesParam('beatsF2',this.value)">
</div>
<div class="pp-hint" style="margin-bottom:8px">f<sub>бие</sub> = |f₁ − f₂|, огибающая — золотая кривая</div>
</div>
<!-- Spectrum controls -->
<div id="waves-spectrum-section" style="display:none">
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Спектр (ДПФ)</div>
<div class="param-block">
<div class="param-header">
<span class="param-name">Гармоника для добавления</span>
<span class="param-val" id="waves-specNewF-val" style="color:var(--pink)">5 Гц</span>
</div>
<input type="range" id="sl-waves-specNewF" class="param-slider" min="1" max="50" step="1" value="5" oninput="wavesParam('specNewF',this.value)" style="accent-color:var(--pink)">
</div>
<div style="display:flex;gap:6px;margin-bottom:8px">
<button class="preset-btn" onclick="wavesSpecAdd()" style="flex:1">Добавить гармонику</button>
<button class="preset-btn" onclick="wavesSpecClear()" style="flex:1;color:var(--pink)">Очистить</button>
</div>
<div class="pp-hint" style="margin-bottom:8px">ДПФ N=256, fs=100 Гц. Пики подписаны в Гц.</div>
</div>
<!-- Presets -->
<div class="gp-section-title" style="margin-top:4px;margin-bottom:6px">Пресеты</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px">
@@ -3067,6 +3195,27 @@
</div>
<div id="angle-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<!-- ── Сечение через 3 точки ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:6px">Сечение через 3 точки</div>
<div class="st-tool-grid" style="margin-bottom:4px">
<button class="st-tool-btn st-tool-btn-wide" id="stereo-sect3p-btn" onclick="stereoSection3P(this)" title="Выбрать 3 точки — построить сечение">
<svg viewBox="0 0 24 24"><circle cx="5" cy="19" r="2.5" fill="currentColor"/><circle cx="12" cy="4" r="2.5" fill="currentColor"/><circle cx="19" cy="14" r="2.5" fill="currentColor"/><polyline points="5,19 12,4 19,14 5,19" fill="none" stroke-dasharray="3,2"/></svg>Сечение через 3 точки
</button>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoSection3PClear()" style="grid-column:span 2">Сбросить сечение</button>
</div>
<div id="sect3p-hint" style="font-size:0.63rem;color:rgba(255,255,255,0.38);margin-top:3px;line-height:1.4"></div>
<div id="sect3p-info" style="font-size:0.7rem;margin-top:4px;line-height:1.6"></div>
<div class="st-toggle-row" style="margin-top:4px" onclick="stereoSection3PStepBy(this.querySelector('.st-toggle'))">
<span class="st-toggle-label"><svg viewBox="0 0 24 24"><line x1="4" y1="12" x2="20" y2="12"/><polyline points="13 5 20 12 13 19"/></svg>Пошагово</span>
<div class="st-toggle" id="stg-sect3p-step"></div>
</div>
<div class="st-action-grid" style="margin-top:3px">
<button class="st-action-btn" onclick="stereoSection3PPrevStep()">Назад</button>
<button class="st-action-btn" onclick="stereoSection3PNextStep()">Вперёд</button>
</div>
<!-- ── Формулы ── -->
<div class="gp-section-title" style="margin-top:8px;margin-bottom:4px">Формулы</div>
<div id="stereo-formulas" style="font-size:0.72rem;color:rgba(255,255,255,0.7);line-height:1.5;margin-bottom:6px"></div>
@@ -3472,6 +3621,11 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/></svg>
Очистить
</button>
<button class="geo-challenge-toggle" id="geo-chall-toggle-btn" onclick="geoToggleChallengePanel()" title="Открыть задачник">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
Задачник
<span class="chall-count" id="geo-chall-count">0/5</span>
</button>
</div>
</div><!-- /.geo-panel -->
@@ -3486,6 +3640,19 @@
<button class="geo-del-btn geo-del-btn-hard" id="geo-del-hard">Со всеми зависимыми</button>
<button class="geo-del-btn geo-del-btn-cancel" id="geo-del-cancel">Отмена</button>
</div>
<!-- Challenge panel (slides in from right) -->
<div class="geo-challenge-panel" id="geo-challenge-panel">
<div class="geo-chall-header">
<span class="geo-chall-header-title">Задачник</span>
<button class="geo-chall-close" onclick="geoToggleChallengePanel()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="geo-chall-list" id="geo-chall-list">
<!-- Populated by JS -->
</div>
</div>
</div>
</div><!-- /.sim-body-wrap -->