5381679c68
Зафиксирована накопленная незакоммиченная работа рабочего дерева, КРОМЕ файлов учебника «Химия 7» (migration 046, chemistry_7_*.html, chem7_svg.js, тест — оставлены незакоммиченными по запросу). Включает: модуль биохимии (ядро BIO, 3D VSEPR, химдвижок, баланс, challenges, пути из БД), System Health Level 1 (вердикт/мониторинг), а также frontend- страницы и lab/textbooks-правки параллельной сессии. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
410 lines
16 KiB
JavaScript
410 lines
16 KiB
JavaScript
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const targetFile = path.join(__dirname, '../../frontend/js/labs/opticsbench.js');
|
|
|
|
const ifSimCode = `/* ─────────────────────────────────────────────────────────────
|
|
4d. INTERFERENCE SIM — Newton's rings / Thin film / Polarization
|
|
Agent C — additive only, class InterferenceSim
|
|
─────────────────────────────────────────────────────────────*/
|
|
class InterferenceSim {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.ctx = canvas.getContext('2d');
|
|
this.W = 0; this.H = 0;
|
|
this.subMode = 'newton';
|
|
// Newton rings
|
|
this.nR = 200;
|
|
this.nNmax = 12;
|
|
// Thin film
|
|
this.tfT = 400;
|
|
this.tfN = 1.33;
|
|
this.tfTheta = 0;
|
|
this.tfPreset = 'soap';
|
|
// Polarization
|
|
this.polTheta = 45;
|
|
this.polSrc = 'unpolarized';
|
|
this._polTick = 0;
|
|
this._polRaf = null;
|
|
this.onUpdate = null;
|
|
new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement || canvas);
|
|
}
|
|
|
|
fit() {
|
|
const p = this.canvas.parentElement;
|
|
if (!p) return;
|
|
const r = p.getBoundingClientRect();
|
|
this.W = this.canvas.width = r.width || p.offsetWidth || 600;
|
|
this.H = this.canvas.height = r.height || p.offsetHeight || 400;
|
|
}
|
|
|
|
setSubMode(sm) {
|
|
this.subMode = sm;
|
|
if (sm === 'polarization') {
|
|
this._polStart();
|
|
} else {
|
|
this._polStop();
|
|
}
|
|
this.draw();
|
|
if (this.onUpdate) this.onUpdate();
|
|
}
|
|
|
|
/* ── Newton Rings ──────────────────────────────────────── */
|
|
_drawNewton() {
|
|
const { ctx, W, H } = this;
|
|
const nm = window._obWavelength || 550;
|
|
const R = this.nR;
|
|
const nMax = this.nNmax;
|
|
const white = window._obWhiteLight;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = '#08081a';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
const topH = Math.floor(H * 0.60);
|
|
const cx = W / 2, cy = topH / 2;
|
|
const maxR_mm = Math.sqrt(nMax * nm * 1e-6 * R);
|
|
const scale = Math.min(cx * 0.85, cy * 0.85) / (maxR_mm || 1);
|
|
|
|
for (let n = nMax; n >= 0; n--) {
|
|
const lambdas = white ? [420, 470, 510, 550, 590, 620, 680] : [nm];
|
|
for (const lam of lambdas) {
|
|
const rDark = Math.sqrt(n * lam * 1e-6 * R) * scale;
|
|
const rBright = Math.sqrt((n + 0.5) * lam * 1e-6 * R) * scale;
|
|
if (rDark > 0.5) {
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, rDark, 0, Math.PI * 2);
|
|
ctx.strokeStyle = white
|
|
? wavelengthToRGB(lam).replace(')', ',0.5)').replace('rgb', 'rgba')
|
|
: '#000000';
|
|
ctx.lineWidth = white ? 1.2 : 1.5;
|
|
ctx.stroke();
|
|
}
|
|
if (rBright > 0.5) {
|
|
const al = white ? 0.22 : 0.55;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, rBright, 0, Math.PI * 2);
|
|
ctx.strokeStyle = wavelengthToRGB(lam).replace(')', ',' + al + ')').replace('rgb', 'rgba');
|
|
ctx.lineWidth = 2.5;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
|
|
ctx.beginPath(); ctx.arc(cx, cy, 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#000000'; ctx.fill();
|
|
|
|
if (window.LabFX && LabFX.glow && !white) {
|
|
const r1b = Math.sqrt(0.5 * nm * 1e-6 * R) * scale;
|
|
LabFX.glow.drawGlow(ctx, cx, cy, r1b, wavelengthToRGB(nm), 18);
|
|
}
|
|
|
|
ctx.beginPath(); ctx.arc(cx, cy, maxR_mm * scale * 1.05, 0, Math.PI * 2);
|
|
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.stroke();
|
|
|
|
const crossY0 = topH + 8;
|
|
const crossH = H - crossY0 - 40;
|
|
if (crossH < 30) return;
|
|
|
|
ctx.fillStyle = '#0d0d20';
|
|
ctx.fillRect(0, crossY0, W, crossH + 36);
|
|
|
|
const glassY = crossY0 + crossH - 10;
|
|
ctx.fillStyle = '#1a3a5c';
|
|
ctx.fillRect(cx - maxR_mm * scale * 1.1, glassY, maxR_mm * scale * 2.2, 10);
|
|
|
|
const sagitta = (maxR_mm * maxR_mm) / (2 * R);
|
|
const sagPx = sagitta * scale;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, glassY - 1 - sagPx, maxR_mm * scale * 1.1, sagPx + 6, 0, 0, Math.PI);
|
|
ctx.fillStyle = 'rgba(100,180,255,0.15)'; ctx.fill();
|
|
ctx.strokeStyle = '#4499cc'; ctx.lineWidth = 1.5; ctx.stroke();
|
|
|
|
for (let n = 0; n <= nMax; n++) {
|
|
const rD = Math.sqrt(n * nm * 1e-6 * R) * scale;
|
|
if (rD < 1) continue;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx + rD, glassY); ctx.lineTo(cx + rD, glassY + 8);
|
|
ctx.moveTo(cx - rD, glassY); ctx.lineTo(cx - rD, glassY + 8);
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
|
}
|
|
|
|
ctx.font = '600 11px monospace'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
|
|
ctx.fillText('Cross-section', cx, crossY0 + 14);
|
|
|
|
const r1d = Math.sqrt(nm * 1e-6 * R).toFixed(3);
|
|
this._drawHUD(ctx, W, H,
|
|
'r1 = sqrt(lam*R) = ' + r1d + ' mm | R=' + R + 'mm | lam=' + nm + 'nm');
|
|
}
|
|
|
|
/* ── Thin Film ─────────────────────────────────────────── */
|
|
_thinFilmColor(t_nm, n_film, theta_deg) {
|
|
const sinR = Math.sin(theta_deg * Math.PI / 180) / n_film;
|
|
const cosR = Math.sqrt(Math.max(0, 1 - sinR * sinR));
|
|
const opd = 2 * n_film * t_nm * cosR;
|
|
let rS = 0, gS = 0, bS = 0;
|
|
for (let lam = 380; lam <= 780; lam += 5) {
|
|
const phase = Math.PI * opd / lam;
|
|
const I = Math.cos(phase) * Math.cos(phase);
|
|
const rgb = wavelengthToRGB(lam);
|
|
const m = rgb.match(/\d+/g);
|
|
if (!m) continue;
|
|
rS += I * +m[0]; gS += I * +m[1]; bS += I * +m[2];
|
|
}
|
|
const sc = 255 / Math.max(rS, gS, bS, 1);
|
|
return 'rgb(' + Math.round(rS * sc) + ',' + Math.round(gS * sc) + ',' + Math.round(bS * sc) + ')';
|
|
}
|
|
|
|
_drawThinFilm() {
|
|
const { ctx, W, H } = this;
|
|
const t = this.tfT;
|
|
const nf = this.tfN;
|
|
const theta = this.tfTheta;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = '#08081a';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
const midY = H * 0.40;
|
|
const filmH = Math.max(28, H * 0.12);
|
|
const margin = W * 0.10;
|
|
const ang = theta * Math.PI / 180;
|
|
const skew = Math.tan(ang) * filmH * 0.5;
|
|
|
|
const grad = ctx.createLinearGradient(margin, 0, W - margin, 0);
|
|
for (let i = 0; i <= 20; i++) {
|
|
const frac = i / 20;
|
|
grad.addColorStop(frac, this._thinFilmColor(t * (0.3 + 0.7 * frac), nf, theta));
|
|
}
|
|
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.moveTo(margin - skew, midY - filmH / 2);
|
|
ctx.lineTo(W - margin - skew, midY - filmH / 2);
|
|
ctx.lineTo(W - margin + skew, midY + filmH / 2);
|
|
ctx.lineTo(margin + skew, midY + filmH / 2);
|
|
ctx.closePath();
|
|
ctx.fillStyle = grad; ctx.fill();
|
|
ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.lineWidth = 1; ctx.stroke();
|
|
ctx.restore();
|
|
|
|
ctx.font = '700 11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('t=' + t + 'nm n=' + nf.toFixed(2), W / 2, midY);
|
|
|
|
const ax2 = W * 0.25, ay2 = midY - filmH / 2;
|
|
const ax1 = ax2 - Math.cos(ang) * 40, ay1 = ay2 - Math.sin(ang) * 40 - 20;
|
|
ctx.beginPath(); ctx.moveTo(ax1, ay1); ctx.lineTo(ax2, ay2);
|
|
ctx.strokeStyle = '#8ab4e8'; ctx.lineWidth = 1.5; ctx.stroke();
|
|
|
|
const col = this._thinFilmColor(t, nf, theta);
|
|
ctx.beginPath(); ctx.moveTo(ax2, ay2); ctx.lineTo(ax2 - Math.cos(ang) * 40, ay1);
|
|
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.stroke();
|
|
|
|
const dx2 = Math.sin(ang) * filmH / nf;
|
|
ctx.beginPath();
|
|
ctx.moveTo(ax2 + dx2, ay2 + filmH);
|
|
ctx.lineTo(ax2 + dx2 - Math.cos(ang) * 40, ay1 + filmH - 20);
|
|
ctx.strokeStyle = col; ctx.lineWidth = 2;
|
|
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.setLineDash([]);
|
|
|
|
const tvX0 = W * 0.55, tvW2 = W * 0.38;
|
|
const tvY0 = H * 0.05, tvH2 = H * 0.60;
|
|
ctx.fillStyle = '#0d0d22'; ctx.strokeStyle = '#2a2a4a'; ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) ctx.roundRect(tvX0, tvY0, tvW2, tvH2, 8);
|
|
else ctx.rect(tvX0, tvY0, tvW2, tvH2);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#555'; ctx.textAlign = 'center';
|
|
ctx.fillText('Top view', tvX0 + tvW2 / 2, tvY0 + 14);
|
|
|
|
const tvRows = 28, tvCols = 36;
|
|
const cW = tvW2 / tvCols, cH = (tvH2 - 20) / tvRows;
|
|
for (let r = 0; r < tvRows; r++) {
|
|
for (let c = 0; c < tvCols; c++) {
|
|
ctx.fillStyle = this._thinFilmColor(t * (0.5 + c / tvCols), nf, theta * (r / tvRows));
|
|
ctx.fillRect(tvX0 + c * cW, tvY0 + 20 + r * cH, cW + 0.5, cH + 0.5);
|
|
}
|
|
}
|
|
|
|
const sinR2 = Math.sin(ang) / nf;
|
|
const cosR2 = Math.sqrt(Math.max(0, 1 - sinR2 * sinR2));
|
|
const opd2 = (2 * nf * t * cosR2).toFixed(0);
|
|
this._drawHUD(ctx, W, H,
|
|
'2nt*cos(th_r)=' + opd2 + 'nm | t=' + t + 'nm n=' + nf.toFixed(2) + ' th=' + theta + 'deg');
|
|
}
|
|
|
|
/* ── Polarization ──────────────────────────────────────── */
|
|
_polStart() {
|
|
if (this._polRaf) return;
|
|
const loop = () => { this._polTick++; this.draw(); this._polRaf = requestAnimationFrame(loop); };
|
|
this._polRaf = requestAnimationFrame(loop);
|
|
}
|
|
|
|
_polStop() {
|
|
if (this._polRaf) { cancelAnimationFrame(this._polRaf); this._polRaf = null; }
|
|
}
|
|
|
|
_drawPolarization() {
|
|
const { ctx, W, H } = this;
|
|
const theta = this.polTheta * Math.PI / 180;
|
|
const I_rel = Math.cos(theta) * Math.cos(theta);
|
|
const tick = this._polTick;
|
|
const white = window._obWhiteLight;
|
|
const nm = window._obWavelength || 550;
|
|
const beamCol = white ? '#ffffff' : wavelengthToRGB(nm);
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
ctx.fillStyle = '#08081a';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
const axisY = H * 0.45;
|
|
const stH = H * 0.38;
|
|
const st = [
|
|
{ x: W * 0.12, label: 'Источник', isFilter: false },
|
|
{ x: W * 0.38, label: 'Поляризатор P1', isFilter: true, angle: 0 },
|
|
{ x: W * 0.64, label: 'Анализатор P2', isFilter: true, angle: this.polTheta },
|
|
{ x: W * 0.88, label: 'Детектор', isFilter: false },
|
|
];
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(st[0].x - 20, axisY); ctx.lineTo(st[3].x + 20, axisY);
|
|
ctx.strokeStyle = '#1a1a35'; ctx.lineWidth = 1; ctx.stroke();
|
|
|
|
const segs = [
|
|
{ x0: st[0].x, x1: st[1].x, amp: 1, unpol: this.polSrc === 'unpolarized', ang: 0 },
|
|
{ x0: st[1].x, x1: st[2].x, amp: 1, unpol: false, ang: 0 },
|
|
{ x0: st[2].x, x1: st[3].x, amp: I_rel, unpol: false, ang: this.polTheta },
|
|
];
|
|
|
|
for (const seg of segs) {
|
|
const nA = 20;
|
|
const sdx = (seg.x1 - seg.x0) / nA;
|
|
for (let i = 0; i <= nA; i++) {
|
|
const bx = seg.x0 + i * sdx;
|
|
const phase = (bx * 0.08 - tick * 0.04) % (Math.PI * 2);
|
|
const bAmp = stH * 0.28 * seg.amp;
|
|
if (seg.unpol) {
|
|
for (let d = 0; d < 4; d++) {
|
|
const a = d * Math.PI / 4;
|
|
const oy = Math.sin(phase + d * 0.7) * bAmp;
|
|
ctx.beginPath(); ctx.moveTo(bx, axisY);
|
|
ctx.lineTo(bx + oy * Math.sin(a) * 0.25, axisY + oy * Math.cos(a));
|
|
ctx.strokeStyle = 'rgba(200,200,255,0.22)'; ctx.lineWidth = 1; ctx.stroke();
|
|
}
|
|
} else {
|
|
const oy = Math.sin(phase) * bAmp;
|
|
const a = seg.ang * Math.PI / 180;
|
|
const py = oy * Math.cos(a), px = oy * Math.sin(a) * 0.35;
|
|
ctx.beginPath();
|
|
ctx.moveTo(bx - px, axisY - py); ctx.lineTo(bx + px, axisY + py);
|
|
ctx.strokeStyle = (I_rel < 0.01 && seg.amp < 0.5)
|
|
? 'rgba(80,80,120,0.5)'
|
|
: beamCol.replace(')', ',0.75)').replace('rgb', 'rgba');
|
|
ctx.lineWidth = 1.5; ctx.stroke();
|
|
if (i % 3 === 0 && bAmp > 2) {
|
|
ctx.beginPath(); ctx.arc(bx + px, axisY + py, 2, 0, Math.PI * 2);
|
|
ctx.fillStyle = beamCol; ctx.fill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const s of st) {
|
|
if (!s.isFilter) continue;
|
|
const a = s.angle * Math.PI / 180;
|
|
ctx.save(); ctx.translate(s.x, axisY);
|
|
ctx.fillStyle = 'rgba(80,120,200,0.18)';
|
|
ctx.fillRect(-4, -stH / 2, 8, stH);
|
|
ctx.strokeStyle = '#4466aa'; ctx.lineWidth = 1.5; ctx.strokeRect(-4, -stH / 2, 8, stH);
|
|
const axLen = stH * 0.45;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-Math.sin(a) * axLen, -Math.cos(a) * axLen);
|
|
ctx.lineTo( Math.sin(a) * axLen, Math.cos(a) * axLen);
|
|
ctx.strokeStyle = '#7aaeff'; ctx.lineWidth = 2; ctx.stroke();
|
|
ctx.restore();
|
|
ctx.font = '700 10px monospace'; ctx.fillStyle = '#7aaeff'; ctx.textAlign = 'center';
|
|
ctx.fillText(s.angle + 'deg', s.x, axisY + stH / 2 + 14);
|
|
}
|
|
|
|
for (const s of st) {
|
|
ctx.font = '600 10px sans-serif'; ctx.fillStyle = '#667788'; ctx.textAlign = 'center';
|
|
ctx.fillText(s.label, s.x, axisY - stH / 2 - 8);
|
|
}
|
|
|
|
const barX = W * 0.91, barW = 16;
|
|
const barY0 = axisY - stH / 2;
|
|
ctx.fillStyle = '#111122'; ctx.fillRect(barX, barY0, barW, stH);
|
|
const fillH2 = stH * I_rel;
|
|
if (fillH2 > 0) {
|
|
const bg = ctx.createLinearGradient(barX, barY0 + stH - fillH2, barX, barY0 + stH);
|
|
bg.addColorStop(0, beamCol); bg.addColorStop(1, 'rgba(0,0,0,0.2)');
|
|
ctx.fillStyle = bg; ctx.fillRect(barX, barY0 + stH - fillH2, barW, fillH2);
|
|
}
|
|
ctx.strokeStyle = '#334455'; ctx.lineWidth = 1; ctx.strokeRect(barX, barY0, barW, stH);
|
|
ctx.font = '600 9px monospace'; ctx.fillStyle = '#aaaaaa'; ctx.textAlign = 'center';
|
|
ctx.fillText('I', barX + barW / 2, barY0 - 5);
|
|
|
|
if (this.polTheta >= 88) {
|
|
ctx.font = '700 13px sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center';
|
|
ctx.fillText('Полное гашение', W / 2, H * 0.85);
|
|
}
|
|
|
|
ctx.font = '10px sans-serif'; ctx.fillStyle = '#444466'; ctx.textAlign = 'right';
|
|
ctx.fillText('Угол Брюстера: отражённый свет поляризован (см. Преломление)', W - 10, H - 10);
|
|
|
|
const pct = (I_rel * 100).toFixed(1);
|
|
this._drawHUD(ctx, W, H,
|
|
'I/I0=cos2(th)=cos2(' + this.polTheta + 'deg)=' + I_rel.toFixed(3) + ' (' + pct + '%)');
|
|
}
|
|
|
|
_drawHUD(ctx, W, H, text) {
|
|
const pad = 8, fs = 11;
|
|
ctx.font = '600 ' + fs + 'px monospace';
|
|
const tw = ctx.measureText(text).width;
|
|
const bx = (W - tw) / 2 - pad, by = H - 32;
|
|
const bw = tw + pad * 2, bh = fs + pad * 2;
|
|
ctx.fillStyle = 'rgba(10,10,30,0.82)';
|
|
ctx.beginPath();
|
|
if (ctx.roundRect) ctx.roundRect(bx, by, bw, bh, 5);
|
|
else ctx.rect(bx, by, bw, bh);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#c8d8ff';
|
|
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, bx + pad, by + bh / 2);
|
|
ctx.textBaseline = 'alphabetic';
|
|
}
|
|
|
|
draw() {
|
|
if (this.subMode === 'newton') this._drawNewton();
|
|
else if (this.subMode === 'thinfilm') this._drawThinFilm();
|
|
else if (this.subMode === 'polarization') this._drawPolarization();
|
|
}
|
|
}
|
|
|
|
`;
|
|
|
|
const src = fs.readFileSync(targetFile, 'utf-8');
|
|
const markerStr = '4c. SPECTROMETER PANEL';
|
|
const markerIdx = src.indexOf(markerStr);
|
|
if (markerIdx < 0) {
|
|
console.error('ERROR: marker not found');
|
|
process.exit(1);
|
|
}
|
|
const insertIdx = src.lastIndexOf('/*', markerIdx);
|
|
if (insertIdx < 0) {
|
|
console.error('ERROR: comment start not found');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Check InterferenceSim not already present
|
|
if (src.indexOf('class InterferenceSim') >= 0) {
|
|
console.log('InterferenceSim already present — skipping JS insertion');
|
|
process.exit(0);
|
|
}
|
|
|
|
const result = src.slice(0, insertIdx) + ifSimCode + src.slice(insertIdx);
|
|
fs.writeFileSync(targetFile, result, 'utf-8');
|
|
console.log('JS insertion OK. New size:', result.length);
|