'use strict'; /* ══════════════════════════════════════════════════════════════ RefractionSim — light refraction simulation (Snell's law) n₁·sin(θ₁) = n₂·sin(θ₂) Total internal reflection · Fresnel coefficients · Dispersion Interactive incident ray drag · Presets ══════════════════════════════════════════════════════════════ */ class RefractionSim { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.W = 0; this.H = 0; /* physics */ this.n1 = 1.0; // refractive index of top medium this.n2 = 1.5; // refractive index of bottom medium this.angle = 30; // incidence angle in degrees /* dispersion mode */ this.dispersion = false; /* drag state */ this._drag = false; /* callback */ this.onUpdate = null; this._bindEvents(); new ResizeObserver(() => { this.fit(); this.draw(); }).observe(canvas.parentElement); } /* ── public API ─────────────────────────────── */ fit() { const dpr = window.devicePixelRatio || 1; const w = this.canvas.offsetWidth || 600; const h = this.canvas.offsetHeight || 400; this.canvas.width = w * dpr; this.canvas.height = h * dpr; this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); this.W = w; this.H = h; } getParams() { return { n1: this.n1, n2: this.n2, angle: this.angle, dispersion: this.dispersion }; } setParams({ n1, n2, angle, dispersion } = {}) { if (n1 !== undefined) this.n1 = Math.max(1.0, Math.min(3.0, +n1)); if (n2 !== undefined) this.n2 = Math.max(1.0, Math.min(3.0, +n2)); if (angle !== undefined) this.angle = Math.max(0, Math.min(89, +angle)); if (dispersion !== undefined) this.dispersion = !!dispersion; this.draw(); this._emit(); } reset() { this.n1 = 1.0; this.n2 = 1.5; this.angle = 30; this.dispersion = false; this.draw(); this._emit(); } info() { const { n1, n2, angle } = this; const theta1Rad = angle * Math.PI / 180; const sinTheta2 = (n1 / n2) * Math.sin(theta1Rad); const isTIR = Math.abs(sinTheta2) > 1; const criticalAngle = n1 > n2 ? +(Math.asin(n2 / n1) * 180 / Math.PI).toFixed(1) : null; let angle2; if (isTIR) { angle2 = 'ПВО'; } else { angle2 = +(Math.asin(sinTheta2) * 180 / Math.PI).toFixed(1); } return { n1: +n1.toFixed(2), n2: +n2.toFixed(2), angle1: +angle.toFixed(1), angle2, criticalAngle, isTIR, }; } /* ── presets ────────────────────────────────── */ static PRESETS = { air_glass: { n1: 1.0, n2: 1.5, angle: 30 }, glass_air: { n1: 1.5, n2: 1.0, angle: 30 }, water_glass: { n1: 1.33, n2: 1.5, angle: 30 }, diamond: { n1: 1.0, n2: 2.42, angle: 45 }, }; /* ── internals ─────────────────────────────── */ _emit() { if (this.onUpdate) this.onUpdate(this.info()); } /* ── draw ──────────────────────────────────── */ draw() { const ctx = this.ctx, W = this.W, H = this.H; if (!W || !H) return; const midY = H / 2; const hitX = W / 2; const hitY = midY; /* --- background: two media --- */ // top medium (lighter) const gradTop = ctx.createLinearGradient(0, 0, 0, midY); gradTop.addColorStop(0, '#131328'); gradTop.addColorStop(1, '#1a1a3a'); ctx.fillStyle = gradTop; ctx.fillRect(0, 0, W, midY); // bottom medium (darker, denser feel) const gradBot = ctx.createLinearGradient(0, midY, 0, H); gradBot.addColorStop(0, '#0e1a2e'); gradBot.addColorStop(1, '#0D0D1A'); ctx.fillStyle = gradBot; ctx.fillRect(0, midY, W, H - midY); /* --- interface line with glow --- */ ctx.save(); ctx.shadowColor = 'rgba(155, 93, 229, 0.4)'; ctx.shadowBlur = 12; ctx.strokeStyle = 'rgba(155, 93, 229, 0.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, midY); ctx.lineTo(W, midY); ctx.stroke(); ctx.restore(); /* --- normal line (dashed vertical) --- */ ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.setLineDash([6, 4]); ctx.beginPath(); ctx.moveTo(hitX, 0); ctx.lineTo(hitX, H); ctx.stroke(); ctx.setLineDash([]); /* --- physics --- */ const theta1Rad = this.angle * Math.PI / 180; const sinTheta2 = (this.n1 / this.n2) * Math.sin(theta1Rad); const isTIR = Math.abs(sinTheta2) > 1; /* Fresnel reflectance (simplified) */ let R = 1; if (!isTIR) { const theta2Rad = Math.asin(sinTheta2); const cosT1 = Math.cos(theta1Rad); const cosT2 = Math.cos(theta2Rad); const rs = (this.n1 * cosT1 - this.n2 * cosT2) / (this.n1 * cosT1 + this.n2 * cosT2); R = rs * rs; } /* ray length (from edge to hit point) */ const rayLen = Math.max(W, H) * 0.6; /* --- critical angle indicator --- */ if (this.n1 > this.n2) { const critRad = Math.asin(this.n2 / this.n1); const critDx = Math.sin(critRad); const critDy = Math.cos(critRad); ctx.strokeStyle = 'rgba(255,209,102,0.25)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); // critical angle ray in top medium ctx.beginPath(); ctx.moveTo(hitX, hitY); ctx.lineTo(hitX - critDx * rayLen * 0.5, hitY - critDy * rayLen * 0.5); ctx.stroke(); ctx.setLineDash([]); // label ctx.font = '10px Manrope, system-ui, sans-serif'; ctx.fillStyle = 'rgba(255,209,102,0.5)'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; const lblX = hitX - critDx * rayLen * 0.35 + 6; const lblY = hitY - critDy * rayLen * 0.35; ctx.fillText('θc=' + (critRad * 180 / Math.PI).toFixed(1) + '°', lblX, lblY); } if (this.dispersion && !isTIR) { this._drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen); } else { this._drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen); } /* --- angle arcs --- */ this._drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR); /* --- medium labels --- */ this._drawMediumLabels(ctx, W, H, midY); /* --- info box --- */ this._drawInfoBox(ctx, isTIR, R); /* --- drag handle indicator (incident ray endpoint) --- */ const incDx = Math.sin(theta1Rad); const incDy = Math.cos(theta1Rad); const handleX = hitX - incDx * rayLen * 0.55; const handleY = hitY - incDy * rayLen * 0.55; const grad = ctx.createRadialGradient(handleX, handleY, 0, handleX, handleY, 10); grad.addColorStop(0, 'rgba(155,93,229,0.4)'); grad.addColorStop(1, 'rgba(155,93,229,0)'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(handleX, handleY, 10, 0, Math.PI * 2); ctx.fill(); } _drawMainRays(ctx, hitX, hitY, midY, theta1Rad, sinTheta2, isTIR, R, rayLen) { const incDx = Math.sin(theta1Rad); const incDy = Math.cos(theta1Rad); /* incident ray */ const incStartX = hitX - incDx * rayLen; const incStartY = hitY - incDy * rayLen; this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#9B5DE5', 2.5); this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#9B5DE5'); /* reflected ray */ const refDx = incDx; // same x component const refDy = -incDy; // flipped y const refEndX = hitX + refDx * rayLen; const refEndY = hitY + refDy * rayLen; // goes up (refDy is negative of incDy) const refAlpha = isTIR ? 1.0 : Math.max(0.3, Math.sqrt(R)); ctx.globalAlpha = refAlpha; this._drawRay(ctx, hitX, hitY, refEndX, refEndY, '#EF476F', 2.5); this._drawArrowhead(ctx, refEndX, refEndY, Math.atan2(refEndY - hitY, refEndX - hitX), '#EF476F'); ctx.globalAlpha = 1; /* refracted ray */ if (!isTIR) { const theta2Rad = Math.asin(sinTheta2); const refracDx = Math.sin(theta2Rad); const refracDy = Math.cos(theta2Rad); const refracEndX = hitX + refracDx * rayLen; const refracEndY = hitY + refracDy * rayLen; const T = 1 - R; ctx.globalAlpha = Math.max(0.3, Math.sqrt(T)); this._drawRay(ctx, hitX, hitY, refracEndX, refracEndY, '#06D6E0', 2.5); this._drawArrowhead(ctx, refracEndX, refracEndY, Math.atan2(refracEndY - hitY, refracEndX - hitX), '#06D6E0'); ctx.globalAlpha = 1; } } _drawDispersion(ctx, hitX, hitY, midY, theta1Rad, rayLen) { /* Cauchy dispersion: n(λ) = A + B/λ² */ const spectral = [ { name: 'red', color: '#FF0000', wave: 656 }, { name: 'orange', color: '#FF7F00', wave: 589 }, { name: 'yellow', color: '#FFFF00', wave: 550 }, { name: 'green', color: '#00FF00', wave: 510 }, { name: 'cyan', color: '#00FFFF', wave: 475 }, { name: 'blue', color: '#0000FF', wave: 450 }, { name: 'violet', color: '#8B00FF', wave: 400 }, ]; /* incident white ray */ const incDx = Math.sin(theta1Rad); const incDy = Math.cos(theta1Rad); const incStartX = hitX - incDx * rayLen; const incStartY = hitY - incDy * rayLen; this._drawRay(ctx, incStartX, incStartY, hitX, hitY, '#FFFFFF', 2.5); this._drawArrowhead(ctx, hitX, hitY, Math.atan2(hitY - incStartY, hitX - incStartX), '#FFFFFF'); /* Cauchy coefficients derived from base n2 */ const A = this.n2 - 4500 / (550 * 550); const B = 4500; for (const s of spectral) { const n2w = A + B / (s.wave * s.wave); const sinT2 = (this.n1 / n2w) * Math.sin(theta1Rad); if (Math.abs(sinT2) > 1) continue; const t2 = Math.asin(sinT2); const dx = Math.sin(t2); const dy = Math.cos(t2); ctx.globalAlpha = 0.85; this._drawRay(ctx, hitX, hitY, hitX + dx * rayLen, hitY + dy * rayLen, s.color, 1.5); ctx.globalAlpha = 1; } /* reflected (white, partial) */ const refDx = incDx; const refDy = -incDy; ctx.globalAlpha = 0.35; this._drawRay(ctx, hitX, hitY, hitX + refDx * rayLen * 0.7, hitY + refDy * rayLen * 0.7, '#FFFFFF', 1.5); ctx.globalAlpha = 1; } _drawRay(ctx, x1, y1, x2, y2, color, width) { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); /* subtle glow */ ctx.save(); ctx.shadowColor = color; ctx.shadowBlur = 8; ctx.globalAlpha = 0.3; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.restore(); } _drawArrowhead(ctx, x, y, angle, color) { const aLen = 10; ctx.fillStyle = color; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - aLen * Math.cos(angle - 0.3), y - aLen * Math.sin(angle - 0.3)); ctx.lineTo(x - aLen * Math.cos(angle + 0.3), y - aLen * Math.sin(angle + 0.3)); ctx.closePath(); ctx.fill(); } _drawAngleArcs(ctx, hitX, hitY, theta1Rad, sinTheta2, isTIR) { const arcR = 50; const font = '12px Manrope, system-ui, sans-serif'; /* θ₁ arc (incidence angle, measured from normal = vertical up) */ if (this.angle > 1) { ctx.strokeStyle = 'rgba(155,93,229,0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); // normal points up from hit: angle = -π/2 in canvas coords // incident ray comes from upper-left // Arc from normal (straight up = -π/2) to incident ray direction const normAngle = -Math.PI / 2; const incAngle = -Math.PI / 2 - theta1Rad; ctx.arc(hitX, hitY, arcR, Math.min(incAngle, normAngle), Math.max(incAngle, normAngle)); ctx.stroke(); // label ctx.font = font; ctx.fillStyle = '#9B5DE5'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const midA = normAngle - theta1Rad / 2; ctx.fillText( 'θ₁=' + this.angle.toFixed(1) + '°', hitX + (arcR + 20) * Math.cos(midA), hitY + (arcR + 20) * Math.sin(midA) ); } /* θ₂ arc (refraction angle, measured from normal = vertical down) */ if (!isTIR && Math.abs(sinTheta2) <= 1) { const theta2Rad = Math.asin(sinTheta2); if (theta2Rad > 0.02) { ctx.strokeStyle = 'rgba(6,214,224,0.6)'; ctx.lineWidth = 1.5; ctx.beginPath(); const normDown = Math.PI / 2; const refAngle = Math.PI / 2 + theta2Rad; ctx.arc(hitX, hitY, arcR * 0.8, Math.min(normDown, refAngle), Math.max(normDown, refAngle)); ctx.stroke(); // label const angle2Deg = theta2Rad * 180 / Math.PI; ctx.font = font; ctx.fillStyle = '#06D6E0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const midA2 = normDown + theta2Rad / 2; ctx.fillText( 'θ₂=' + angle2Deg.toFixed(1) + '°', hitX + (arcR * 0.8 + 20) * Math.cos(midA2), hitY + (arcR * 0.8 + 20) * Math.sin(midA2) ); } } } _drawMediumLabels(ctx, W, H, midY) { ctx.font = '13px Manrope, system-ui, sans-serif'; ctx.textBaseline = 'middle'; /* top medium */ ctx.fillStyle = 'rgba(155,93,229,0.6)'; ctx.textAlign = 'left'; ctx.fillText('n₁ = ' + this.n1.toFixed(2), 16, midY - 30); /* bottom medium */ ctx.fillStyle = 'rgba(6,214,224,0.6)'; ctx.fillText('n₂ = ' + this.n2.toFixed(2), 16, midY + 30); /* TIR badge */ const theta1Rad = this.angle * Math.PI / 180; const sinT2 = (this.n1 / this.n2) * Math.sin(theta1Rad); if (Math.abs(sinT2) > 1) { ctx.font = 'bold 14px Manrope, system-ui, sans-serif'; ctx.fillStyle = '#EF476F'; ctx.textAlign = 'center'; ctx.fillText('Полное внутреннее отражение (ПВО)', W / 2, midY + 60); } } _drawInfoBox(ctx, isTIR, R) { const boxW = 220, boxH = 72; const bx = this.W - boxW - 12, by = 12; ctx.fillStyle = 'rgba(22,22,38,0.85)'; ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 8); ctx.fill(); ctx.font = '11px Manrope, system-ui, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fillText('n₁·sin(θ₁) = n₂·sin(θ₂)', bx + 10, by + 10); const info = this.info(); ctx.fillStyle = 'rgba(255,255,255,0.5)'; const a2str = info.isTIR ? 'ПВО' : info.angle2 + '°'; ctx.fillText(`θ₁ = ${info.angle1}° θ₂ = ${a2str}`, bx + 10, by + 28); const rPct = (R * 100).toFixed(1); const tPct = ((1 - R) * 100).toFixed(1); ctx.fillStyle = '#EF476F'; ctx.fillText(`R = ${rPct}%`, bx + 10, by + 46); ctx.fillStyle = '#06D6E0'; ctx.fillText(`T = ${isTIR ? '0' : tPct}%`, bx + 90, by + 46); if (info.criticalAngle !== null) { ctx.fillStyle = '#FFD166'; ctx.fillText(`θc = ${info.criticalAngle}°`, bx + 160, by + 46); } } /* ── events ─────────────────────────────────── */ _bindEvents() { const cv = this.canvas; const getPos = (e) => { const r = cv.getBoundingClientRect(); const t = e.touches ? e.touches[0] : e; return { mx: (t.clientX - r.left) * (this.W / r.width), my: (t.clientY - r.top) * (this.H / r.height), }; }; const hitTest = (mx, my) => { /* Check if near the incident ray line (top half only) */ const hitX = this.W / 2; const hitY = this.H / 2; if (my >= hitY) return false; /* distance from mouse to the hit point — if within top half, allow drag */ const dx = mx - hitX; const dy = my - hitY; const dist = Math.hypot(dx, dy); return dist > 20 && dist < Math.max(this.W, this.H) * 0.6; }; const angleFromMouse = (mx, my) => { const hitX = this.W / 2; const hitY = this.H / 2; const dx = mx - hitX; const dy = hitY - my; // flip: canvas y goes down, angle measured from vertical up // angle from vertical = atan2(|dx|, dy) const a = Math.atan2(Math.abs(dx), dy) * 180 / Math.PI; return Math.max(0, Math.min(89, a)); }; const onDown = (e) => { const { mx, my } = getPos(e); if (hitTest(mx, my)) this._drag = true; }; const onMove = (e) => { if (!this._drag) return; if (e.cancelable) e.preventDefault(); const { mx, my } = getPos(e); this.angle = angleFromMouse(mx, my); this.draw(); this._emit(); }; const onUp = () => { this._drag = false; }; /* mouse */ cv.addEventListener('mousedown', onDown); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); /* touch */ cv.addEventListener('touchstart', e => { if (e.touches.length === 1) onDown(e); }, { passive: true }); cv.addEventListener('touchmove', e => onMove(e), { passive: false }); cv.addEventListener('touchend', onUp); /* cursor style */ cv.addEventListener('mousemove', e => { if (this._drag) { cv.style.cursor = 'grabbing'; return; } const { mx, my } = getPos(e); cv.style.cursor = hitTest(mx, my) ? 'grab' : 'default'; }); } }