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
+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;