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:
+234
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user