Files
Learn_System/frontend/js/optics.js
Maxim Dolgolyov 33a91900a8 feat(phys8): Phase 0 — skeleton hub + 3 chapters + lab + phys.js/optics.js
Полная инфраструктура курса «Физика 8» (Исаченкова, 2018):
- physics_8_hub.html: палитра violet/indigo, 3 главы + ЛР + финал курса
  с 10 интегрированными боссами и ачивкой «Магистр физики 8» (+150 XP)
- physics_8_ch1.html (Тепловые, §§1–11): красный акцент
- physics_8_ch2.html (Электромагнитные, §§12–31): янтарный акцент
- physics_8_ch3.html (Световые, §§32–40): голубой акцент
- physics_8_lab.html (7 ЛР): зелёный акцент
- Расширение phys.js: tempColor, thermometer, calorimeter, createHeatBar,
  phaseGraphTT, Rseries, Rparallel
- Новый модуль optics.js: ray, refractRay, reflectRay, mirrorPlane,
  mirrorSpherical, thinLens, buildLensImage, goldenRays, eyeDiagram,
  lightObject, shadowTriangle
- Миграция 037: replace legacy children (thermal/electro/optics) на
  physics-8-ch1/ch2/ch3 + physics-8-lab; обновлён hub до 47 пунктов

BUILDERS всех § рендерят stub с указанием Phase/Wave из PLAN_PHYSICS_8.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:41:37 +03:00

285 lines
14 KiB
JavaScript

// optics.js — модуль хелперов геометрической оптики для учебника Физика 8
// Экспорт в window.OPTICS = { ... }
(function(){
'use strict';
// === Палитра ===
const COLOR = {
ray: '#fbbf24',
rayIncident:'#0891b2',
rayReflected:'#10b981',
rayRefracted:'#a855f7',
normal: '#94a3b8',
lensConv: '#22c55e',
lensDiv: '#f97316',
mirror: '#475569',
hatch: '#94a3b8',
axis: '#cbd5e1',
focus: '#1d4ed8',
imageReal: '#7c3aed',
imageVirtual:'#a78bfa'
};
// === Стрелка-наконечник (внутренний помощник) ===
function arrowHead(x, y, ux, uy, size, color){
const w = size * 0.55;
const px = -uy, py = ux;
const bx = x - ux*size, by = y - uy*size;
const lx = bx + px*w, ly = by + py*w;
const rx = bx - px*w, ry = by - py*w;
return `<polygon points="${x.toFixed(1)},${y.toFixed(1)} ${lx.toFixed(1)},${ly.toFixed(1)} ${rx.toFixed(1)},${ry.toFixed(1)}" fill="${color}"/>`;
}
// === Луч (линия + стрелка посередине) ===
// dashed: true → пунктир (для виртуальных продолжений)
function ray(x1, y1, x2, y2, color, dashed){
color = color || COLOR.ray;
const dx = x2 - x1, dy = y2 - y1;
const len = Math.sqrt(dx*dx + dy*dy);
if (len < 1e-6) return '';
const ux = dx/len, uy = dy/len;
const dash = dashed ? ' stroke-dasharray="6 4"' : '';
let s = '';
s += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${color}" stroke-width="2" stroke-linecap="round"${dash}/>`;
// Стрелка-наконечник в точке (x2,y2)
if (!dashed) s += arrowHead(x2, y2, ux, uy, 9, color);
return s;
}
// === Падающий, отражённый, преломлённый лучи + нормаль ===
// На границе двух сред (горизонтальной) с нормалью вверх
function refractRay(x0, y0, angleInDeg, n1, n2, len){
// angleInDeg — угол падения от нормали (в градусах)
// Возвращает SVG: падающий луч (сверху-слева к (x0,y0)) + нормаль + отражённый + преломлённый
len = len || 80;
const a1 = angleInDeg * Math.PI / 180;
const sinA2 = (n1/n2) * Math.sin(a1);
const tir = Math.abs(sinA2) > 1; // полное внутреннее отражение
const a2 = tir ? 0 : Math.asin(sinA2);
let s = '';
// Граница
s += `<line x1="${x0-len*1.5}" y1="${y0}" x2="${x0+len*1.5}" y2="${y0}" stroke="${COLOR.mirror}" stroke-width="1.5"/>`;
// Нормаль (вверх и вниз пунктиром)
s += `<line x1="${x0}" y1="${y0-len*0.9}" x2="${x0}" y2="${y0+len*0.9}" stroke="${COLOR.normal}" stroke-width="1.2" stroke-dasharray="5 3"/>`;
// Падающий луч (приходит сверху-слева)
const xi = x0 - len*Math.sin(a1), yi = y0 - len*Math.cos(a1);
s += ray(xi, yi, x0, y0, COLOR.rayIncident);
// Отражённый луч (вверх-вправо)
const xr = x0 + len*Math.sin(a1), yr = y0 - len*Math.cos(a1);
s += ray(x0, y0, xr, yr, COLOR.rayReflected);
// Преломлённый луч (вниз)
if (!tir){
const xt = x0 + len*Math.sin(a2), yt = y0 + len*Math.cos(a2);
s += ray(x0, y0, xt, yt, COLOR.rayRefracted);
}
return { svg: s, a1: a1, a2: a2, tir: tir };
}
// === Отражение от плоского зеркала ===
// Зеркало горизонтальное на y = y0
function reflectRay(x0, y0, angleInDeg, len){
len = len || 80;
const a = angleInDeg * Math.PI / 180;
let s = '';
// Зеркало
s += `<line x1="${x0-len*1.4}" y1="${y0}" x2="${x0+len*1.4}" y2="${y0}" stroke="${COLOR.mirror}" stroke-width="2"/>`;
// Штриховка с обратной стороны
for (let i = -len; i <= len; i += 8){
s += `<line x1="${x0+i}" y1="${y0}" x2="${x0+i+5}" y2="${y0+6}" stroke="${COLOR.hatch}" stroke-width="1"/>`;
}
// Нормаль
s += `<line x1="${x0}" y1="${y0-len*0.9}" x2="${x0}" y2="${y0+10}" stroke="${COLOR.normal}" stroke-width="1.2" stroke-dasharray="5 3"/>`;
// Падающий
const xi = x0 - len*Math.sin(a), yi = y0 - len*Math.cos(a);
s += ray(xi, yi, x0, y0, COLOR.rayIncident);
// Отражённый
const xr = x0 + len*Math.sin(a), yr = y0 - len*Math.cos(a);
s += ray(x0, y0, xr, yr, COLOR.rayReflected);
return s;
}
// === Плоское зеркало (отдельный элемент) ===
function mirrorPlane(x, y, len, angleDeg){
angleDeg = angleDeg || 0;
const a = angleDeg * Math.PI / 180;
const ca = Math.cos(a), sa = Math.sin(a);
const x1 = x - len/2*ca, y1 = y - len/2*sa;
const x2 = x + len/2*ca, y2 = y + len/2*sa;
const nx = -sa, ny = ca; // нормаль "наружу"
let s = `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${COLOR.mirror}" stroke-width="2.5"/>`;
// Штриховка с тыльной стороны
for (let t = -len/2 + 3; t <= len/2; t += 8){
const bx = x + t*ca, by = y + t*sa;
s += `<line x1="${bx.toFixed(1)}" y1="${by.toFixed(1)}" x2="${(bx - nx*6).toFixed(1)}" y2="${(by - ny*6).toFixed(1)}" stroke="${COLOR.hatch}" stroke-width="1"/>`;
}
return s;
}
// === Сферическое зеркало (вогнутое/выпуклое) ===
// kind: 'concave' (вогнутое — фокусирует) | 'convex' (выпуклое — рассеивает)
function mirrorSpherical(cx, cy, R, kind, halfH){
halfH = halfH || R * 0.6;
const sign = (kind === 'convex') ? -1 : 1;
// Дуга
const x1 = cx - sign*R*0.15, y1 = cy - halfH;
const x2 = cx - sign*R*0.15, y2 = cy + halfH;
const sweep = sign > 0 ? 0 : 1;
let s = `<path d="M ${x1} ${y1} A ${R} ${R} 0 0 ${sweep} ${x2} ${y2}" fill="none" stroke="${COLOR.mirror}" stroke-width="2.5"/>`;
// Главная оптическая ось
s += `<line x1="${cx-R}" y1="${cy}" x2="${cx+R}" y2="${cy}" stroke="${COLOR.axis}" stroke-width="1" stroke-dasharray="4 3"/>`;
return s;
}
// === Тонкая линза ===
// kind: 'converging' (двусторонне-выпуклая, |) | 'diverging' (двусторонне-вогнутая, |)
// Возвращает SVG с осью, фокусами F и 2F
function thinLens(cx, cy, halfH, F, kind){
halfH = halfH || 70;
F = F || 80;
const color = (kind === 'diverging') ? COLOR.lensDiv : COLOR.lensConv;
let s = '';
// Главная оптическая ось
s += `<line x1="${cx - 2.5*F}" y1="${cy}" x2="${cx + 2.5*F}" y2="${cy}" stroke="${COLOR.axis}" stroke-width="1.2"/>`;
// Линза — вертикальный овал
s += `<line x1="${cx}" y1="${cy-halfH}" x2="${cx}" y2="${cy+halfH}" stroke="${color}" stroke-width="2.5"/>`;
if (kind === 'diverging'){
// Стрелки наружу (рассеивающая)
s += arrowHead(cx, cy - halfH, 0, -1, 10, color);
s += arrowHead(cx, cy + halfH, 0, 1, 10, color);
} else {
// Стрелки внутрь (собирающая) — вершины на оси
s += arrowHead(cx - 6, cy - halfH + 8, -0.6, -0.8, 10, color);
s += arrowHead(cx + 6, cy - halfH + 8, 0.6, -0.8, 10, color);
s += arrowHead(cx - 6, cy + halfH - 8, -0.6, 0.8, 10, color);
s += arrowHead(cx + 6, cy + halfH - 8, 0.6, 0.8, 10, color);
}
// Фокусы F и 2F
s += `<circle cx="${cx-F}" cy="${cy}" r="3" fill="${COLOR.focus}"/>`;
s += `<circle cx="${cx+F}" cy="${cy}" r="3" fill="${COLOR.focus}"/>`;
s += `<text x="${cx-F-3}" y="${cy+18}" font-family="JetBrains Mono,monospace" font-size="12" font-weight="700" fill="${COLOR.focus}">F</text>`;
s += `<text x="${cx+F-3}" y="${cy+18}" font-family="JetBrains Mono,monospace" font-size="12" font-weight="700" fill="${COLOR.focus}">F</text>`;
s += `<circle cx="${cx-2*F}" cy="${cy}" r="2.5" fill="${COLOR.focus}" opacity=".6"/>`;
s += `<circle cx="${cx+2*F}" cy="${cy}" r="2.5" fill="${COLOR.focus}" opacity=".6"/>`;
s += `<text x="${cx-2*F-5}" y="${cy+18}" font-family="JetBrains Mono,monospace" font-size="11" opacity=".7" fill="${COLOR.focus}">2F</text>`;
s += `<text x="${cx+2*F-5}" y="${cy+18}" font-family="JetBrains Mono,monospace" font-size="11" opacity=".7" fill="${COLOR.focus}">2F</text>`;
return s;
}
// === Построение изображения в тонкой линзе ===
// F — фокусное расстояние (положительное для собирающей, отрицательное для рассеивающей)
// d — расстояние от предмета до линзы (положительное)
// h — высота предмета (положительная)
// Возвращает { f: расстояние до изображения, h2: высота, virtual: bool, kind: 'real'|'virtual' }
function buildLensImage(F, d, h){
// 1/F = 1/d + 1/f → f = d·F / (d - F)
if (Math.abs(d - F) < 1e-6){
return { f: Infinity, h2: -Infinity, virtual: false, kind: 'infinity' };
}
const f = d * F / (d - F);
const h2 = -h * f / d; // увеличение: h2/h = -f/d
const virtual = (F > 0) ? (d < F) : true; // для рассеивающей всегда мнимое
return { f: f, h2: h2, virtual: virtual, kind: virtual ? 'virtual' : 'real' };
}
// === Три "золотых" луча для построения изображения ===
// Возвращает SVG трёх лучей: через центр, параллельно оси, через передний фокус.
// objX, objY — координата предмета (вершина стрелки)
// lensX, lensY — центр линзы
// F (px) — фокусное расстояние (в пикселях)
function goldenRays(objX, objY, lensX, lensY, F){
let s = '';
const dx = lensX - objX;
const dy = lensY - objY;
// Луч 1: параллельно оси → проходит через F с другой стороны
const y1At = objY; // приходит на линзу на высоте objY
s += ray(objX, y1At, lensX, y1At, COLOR.rayIncident);
s += ray(lensX, y1At, lensX + 2.5*F, lensY + (y1At - lensY) * (-2.5*F)/(F * (objY < lensY ? 1 : -1)), COLOR.rayIncident);
// Луч 2: через центр — продолжается без преломления
s += ray(objX, objY, lensX + dx, lensY + dy, COLOR.rayReflected);
// Луч 3: через передний фокус → выходит параллельно оси
// (вычислим точку пересечения с линзой)
const slope = (lensY - objY) / (objX - (lensX - F));
const yAtLens = lensY - slope * F; // упрощённо
s += ray(objX, objY, lensX, yAtLens, COLOR.rayRefracted);
s += ray(lensX, yAtLens, lensX + 2*F, yAtLens, COLOR.rayRefracted);
return s;
}
// === Глаз (упрощённая схема) ===
// accommodation: 0..1, где 0 — расслабленный (дальний предмет), 1 — напряжённый (близкий)
function eyeDiagram(cx, cy, R, accommodation){
accommodation = accommodation || 0;
let s = '';
// Глазное яблоко
s += `<circle cx="${cx}" cy="${cy}" r="${R}" fill="#fff" stroke="#0f172a" stroke-width="1.6"/>`;
// Роговица — выпуклость спереди (слева)
s += `<path d="M ${cx-R*0.95} ${cy-R*0.5} Q ${cx-R*1.15} ${cy} ${cx-R*0.95} ${cy+R*0.5}" fill="#bfdbfe" stroke="#0f172a" stroke-width="1.4"/>`;
// Хрусталик — эллипс изнутри, форма зависит от accommodation
const lensHalfH = R * 0.45;
const lensW = R * (0.10 + 0.10 * accommodation); // толще при напряжении
s += `<ellipse cx="${cx-R*0.6}" cy="${cy}" rx="${lensW}" ry="${lensHalfH}" fill="#fef3c7" stroke="${COLOR.lensConv}" stroke-width="1.6"/>`;
// Сетчатка — задняя часть
s += `<path d="M ${cx+R*0.95} ${cy-R*0.8} A ${R} ${R} 0 0 1 ${cx+R*0.95} ${cy+R*0.8}" fill="none" stroke="${COLOR.imageReal}" stroke-width="2"/>`;
// Зрительный нерв
s += `<path d="M ${cx+R} ${cy} L ${cx+R*1.4} ${cy+R*0.3}" stroke="#0f172a" stroke-width="2.5"/>`;
return s;
}
// === Источник света / предмет ===
// kind: 'point' (точечный звезда) | 'arrow' (вертикальная стрелка-предмет)
function lightObject(x, y, h, kind){
kind = kind || 'arrow';
if (kind === 'point'){
let s = `<circle cx="${x}" cy="${y}" r="6" fill="${COLOR.ray}" stroke="#0f172a" stroke-width="1"/>`;
// Лучики
for (let i = 0; i < 8; i++){
const a = i * Math.PI / 4;
const x1 = x + 8*Math.cos(a), y1 = y + 8*Math.sin(a);
const x2 = x + 13*Math.cos(a), y2 = y + 13*Math.sin(a);
s += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${COLOR.ray}" stroke-width="1.5" stroke-linecap="round"/>`;
}
return s;
}
// Стрелка-предмет: основание на оси (y), вершина на (y - h)
const tipY = y - h;
const color = '#0f172a';
let s = `<line x1="${x}" y1="${y}" x2="${x}" y2="${tipY}" stroke="${color}" stroke-width="2.5"/>`;
s += arrowHead(x, tipY, 0, -1, 9, color);
return s;
}
// === Тень и полутень ===
// Источник в (sx, sy), непрозрачный объект — отрезок [ox-or .. ox+or] на высоте oy.
// Возвращает SVG полупрозрачных треугольников тени/полутени, падающих на экран y = screenY.
function shadowTriangle(sx, sy, ox, oy, or, screenY){
// Касательные от источника к краям объекта дают полутень.
// Лучи через центр объекта дают полную тень.
// Простейшая реализация для точечного источника: только тень.
const dy = screenY - sy;
const k = dy / (oy - sy);
const x1 = sx + (ox - or - sx) * k;
const x2 = sx + (ox + or - sx) * k;
let s = '';
s += `<polygon points="${(ox-or).toFixed(1)},${oy} ${(ox+or).toFixed(1)},${oy} ${x2.toFixed(1)},${screenY} ${x1.toFixed(1)},${screenY}" fill="rgba(15,23,42,.35)" stroke="none"/>`;
return s;
}
// === Экспорт ===
window.OPTICS = {
COLOR: COLOR,
ray: ray,
refractRay: refractRay,
reflectRay: reflectRay,
mirrorPlane: mirrorPlane,
mirrorSpherical: mirrorSpherical,
thinLens: thinLens,
buildLensImage: buildLensImage,
goldenRays: goldenRays,
eyeDiagram: eyeDiagram,
lightObject: lightObject,
shadowTriangle: shadowTriangle
};
})();