<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Virtual Diffraction Grating · Colour Mixing</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #f5f5f5; font-family: 'Helvetica Neue', Arial, sans-serif; font-size: 13px; color: #222; }
.wrapper { max-width: 900px; margin: 0 auto; padding: 24px 20px 48px; }
.explainer {
background: #fff; border: 1px solid #ccc; border-left: 3px solid #3366cc;
border-radius: 3px; padding: 9px 14px; margin-bottom: 14px;
font-size: 12.5px; color: #444; line-height: 1.75;
}
.bench-wrap { border: 1px solid #bbb; border-radius: 3px; background: #fff; margin-bottom: 14px; overflow: hidden; }
#bench { display: block; width: 100%; height: auto; }
.main-grid { display: grid; grid-template-columns: 1fr 200px; gap: 14px; margin-bottom: 14px; align-items: start; }
@media (max-width: 580px) { .main-grid { grid-template-columns: 1fr; } }
.panel { background: #fff; border: 1px solid #ccc; border-radius: 3px; padding: 13px 15px; }
.panel-title { font-size: 10.5px; font-weight: 700; color: #666; text-transform: uppercase; letter-spacing: 0.09em; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #eee; }
.sl-row { display: grid; grid-template-columns: 64px 1fr 36px; align-items: center; gap: 9px; padding: 4px 0; border-bottom: 1px solid #f0f0f0; }
.sl-row:last-child { border-bottom: none; }
.sl-label { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.sl-swatch { width: 9px; height: 9px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0; }
input[type=range] { -webkit-appearance: none; appearance: none; height: 5px; border-radius: 3px; outline: none; cursor: pointer; width: 100%; background: #ddd; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; border: 2px solid #777; cursor: pointer; }
input[type=range]::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: 2px solid #777; cursor: pointer; }
.sl-pct { font-size: 11px; color: #777; text-align: right; }
.output-panel { display: flex; flex-direction: column; align-items: center; }
.output-circle-wrap { width: 110px; height: 110px; border-radius: 50%; border: 2px solid #bbb; display: flex; align-items: center; justify-content: center; margin: 8px auto 12px; background: #eee; }
#output-dot { width: 96px; height: 96px; border-radius: 50%; background: #fff; border: 1px solid rgba(0,0,0,0.1); transition: background 0.12s; }
.color-readout { width: 100%; background: #f8f8f8; border: 1px solid #ddd; border-radius: 3px; padding: 7px 9px; margin-bottom: 7px; font-size: 11px; font-family: 'Courier New', monospace; }
.cr-row { display: flex; gap: 6px; margin-bottom: 3px; }
.cr-row:last-child { margin-bottom: 0; }
.cr-lbl { color: #999; min-width: 14px; }
#color-desc { font-size: 11px; color: #666; text-align: center; line-height: 1.5; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.preset-btn { background: #f4f4f4; border: 1px solid #ccc; color: #333; font-family: inherit; font-size: 11px; padding: 5px 11px; border-radius: 3px; cursor: pointer; transition: border-color 0.1s, background 0.1s; }
.preset-btn:hover { border-color: #3366cc; background: #eef2ff; }
.preset-btn.active { border-color: #3366cc; background: #dce6ff; color: #1144aa; font-weight: 600; }
</style>
</head>
<body>
<div class="wrapper">
<div class="explainer">
<strong>How it works:</strong> White light source <strong>①</strong> emits diverging rays
that are collimated by lens <strong>②</strong>. The parallel beam hits diffraction grating
<strong>③</strong>, splitting it into its spectral colors <strong>④</strong> which are shown at filter plane
<strong>⑤</strong>. You can selectively block individual spectral components - this is what happens when an absorbing component is placed into the beam. A second lens <strong>⑥</strong> finally
recombines the remaining spectral components into a single output spot <strong>⑦</strong>.
<br><br> If all spectral components are recombined, the output spot is white. If specific spectral components are removed from the light, the output spot shows a color.
</div>
<div class="bench-wrap">
<canvas id="bench" width="880" height="300"></canvas>
</div>
<div class="main-grid">
<div class="panel">
<div class="panel-title">Spectral transmission at filter plane ⑤</div>
<div id="slider-list"></div>
</div>
<div class="panel output-panel">
<div class="panel-title">Recombined output ⑦</div>
<div class="output-circle-wrap"><div id="output-dot"></div></div>
<div class="color-readout">
<div class="cr-row"><span class="cr-lbl">HEX</span><span id="hex-val">#FFFFFF</span></div>
<div class="cr-row">
<span class="cr-lbl">R</span><span id="r-val">255</span> 
<span class="cr-lbl">G</span><span id="g-val">255</span> 
<span class="cr-lbl">B</span><span id="b-val">255</span>
</div>
</div>
<div id="color-desc">All bands active → white</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Quick presets</div>
<div class="preset-grid" id="preset-grid"></div>
</div>
</div><!-- /wrapper -->
<script>
// ═══════════════════════════════════════════════════════
// SPECTRAL BANDS
// hex = vivid display colour for beams, sliders, AND output dot
// ═══════════════════════════════════════════════════════
const BANDS = [
{ name:'Violet', hex:'#9900cc' },
{ name:'Blue', hex:'#0044ff' },
{ name:'Cyan', hex:'#00c8e0' },
{ name:'Green', hex:'#22dd00' },
{ name:'Yellow', hex:'#eeee00' },
{ name:'Orange', hex:'#ff8800' },
{ name:'Red', hex:'#cc0000' },
];
function hexRGB(hex) {
return {
r: parseInt(hex.slice(1,3),16),
g: parseInt(hex.slice(3,5),16),
b: parseInt(hex.slice(5,7),16)
};
}
// Per-channel totals — guarantees white when all sv[i]=1
const totR = BANDS.reduce((s,b) => s + hexRGB(b.hex).r, 0);
const totG = BANDS.reduce((s,b) => s + hexRGB(b.hex).g, 0);
const totB = BANDS.reduce((s,b) => s + hexRGB(b.hex).b, 0);
const sv = new Array(7).fill(1.0); // slider values [0..1]
// ──────────────────────────────────────────────────────
// mixColors():
// Step 1 — per-channel normalisation → r,g,b each reach
// exactly 255 when ALL bands are at 100%.
// This guarantees WHITE for the all-on case.
// Step 2 — max-scale to full brightness so that a single
// active band produces the same vivid colour as
// the beam/slider, not a dim fraction of it.
// ──────────────────────────────────────────────────────
function mixColors() {
let rn=0, gn=0, bn=0;
BANDS.forEach((band,i) => {
const c = hexRGB(band.hex);
rn += c.r * sv[i] / totR;
gn += c.g * sv[i] / totG;
bn += c.b * sv[i] / totB;
});
// rn,gn,bn ∈ [0,1] → scale to [0,255]
let r = rn*255, g = gn*255, b = bn*255;
const maxC = Math.max(r, g, b);
if (maxC < 1) return {r:0, g:0, b:0};
const s = 255/maxC;
return {
r: Math.min(255, Math.round(r*s)),
g: Math.min(255, Math.round(g*s)),
b: Math.min(255, Math.round(b*s)),
};
}
function toHex(c) { return '#'+[c.r,c.g,c.b].map(v=>v.toString(16).padStart(2,'0')).join('').toUpperCase(); }
function rgba(hex, a) {
const {r,g,b} = hexRGB(hex);
return `rgba(${r},${g},${b},${a})`;
}
// ═══════════════════════════════════════════════════════
// PRESETS (index: 0=Violet 1=Blue 2=Cyan 3=Green
// 4=Yellow 5=Orange 6=Red)
// ═══════════════════════════════════════════════════════
const PRESETS = [
{ label:'White (all)', v:[1,1,1,1,1,1,1], desc:'All 7 bands active → white' },
{ label:'No green', v:[1,1,1,0,1,1,1], desc:'Green removed → magenta / pink' },
{ label:'No red', v:[1,1,1,1,1,1,0], desc:'Red removed → cyan' },
{ label:'No blue', v:[1,0,1,1,1,1,1], desc:'Blue removed → warm yellow' },
{ label:'Warm only', v:[0,0,0,0,1,1,1], desc:'Yellow + orange + red → warm orange' },
{ label:'Cool only', v:[1,1,1,0,0,0,0], desc:'Violet + blue + cyan → cool blue' },
{ label:'Red + Blue', v:[0,1,0,0,0,0,1], desc:'Non-spectral: red + blue → purple' }, // FIX: was Orange+Red
{ label:'Red only', v:[0,0,0,0,0,0,1], desc:'Single band: deep red' },
{ label:'Green only', v:[0,0,0,1,0,0,0], desc:'Single band: green' },
{ label:'All blocked', v:[0,0,0,0,0,0,0], desc:'No transmission → black' },
];
// ═══════════════════════════════════════════════════════
// UI
// ═══════════════════════════════════════════════════════
function buildSliders() {
const cont = document.getElementById('slider-list');
BANDS.forEach((band, i) => {
const row = document.createElement('div');
row.className = 'sl-row';
row.innerHTML = `
<div class="sl-label">
<span class="sl-swatch" style="background:${band.hex}"></span>
<span>${band.name}</span>
</div>
<input type="range" id="sl-${i}" min="0" max="100" value="100">
<span class="sl-pct" id="pct-${i}">100%</span>`;
cont.appendChild(row);
const inp = document.getElementById(`sl-${i}`);
inp.addEventListener('input', () => {
sv[i] = parseInt(inp.value)/100;
document.getElementById(`pct-${i}`).textContent = Math.round(sv[i]*100)+'%';
setTrack(inp, band.hex, sv[i]);
updateOutput(); clearActive(); redraw();
});
setTrack(inp, band.hex, 1);
});
}
function setTrack(inp, hex, v) {
const p = v*100;
inp.style.background = `linear-gradient(to right,${hex} 0%,${hex} ${p}%,#ddd ${p}%)`;
}
function syncSliders() {
BANDS.forEach((_,i) => {
const inp = document.getElementById(`sl-${i}`);
inp.value = Math.round(sv[i]*100);
document.getElementById(`pct-${i}`).textContent = Math.round(sv[i]*100)+'%';
setTrack(inp, BANDS[i].hex, sv[i]);
});
}
function buildPresets() {
const grid = document.getElementById('preset-grid');
PRESETS.forEach((p,pi) => {
const btn = document.createElement('button');
btn.className = 'preset-btn'; btn.id = `pb-${pi}`;
btn.textContent = p.label;
btn.addEventListener('click', () => {
p.v.forEach((val,i) => sv[i] = val);
syncSliders(); updateOutput(p.desc); redraw();
grid.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
grid.appendChild(btn);
});
}
function clearActive() { document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active')); }
function describeColor() {
if (sv.every(v=>v>0.95)) return 'All 7 bands active → white';
if (sv.every(v=>v<0.05)) return 'All bands blocked → black';
const blocked = BANDS.filter((_,i)=>sv[i]<0.05).map(b=>b.name.toLowerCase());
return blocked.length ? 'Removed: '+blocked.join(', ') : '';
}
function updateOutput(desc) {
const c = mixColors(), hex = toHex(c);
document.getElementById('output-dot').style.background = hex;
document.getElementById('hex-val').textContent = hex;
document.getElementById('r-val').textContent = c.r;
document.getElementById('g-val').textContent = c.g;
document.getElementById('b-val').textContent = c.b;
document.getElementById('color-desc').textContent = desc || describeColor();
}
// ═══════════════════════════════════════════════════════
// CANVAS — OPTICAL BENCH
// ═══════════════════════════════════════════════════════
const canvas = document.getElementById('bench');
const ctx = canvas.getContext('2d');
const DPR = Math.min(window.devicePixelRatio||1, 2);
const CW=880, CH=300;
canvas.width = CW*DPR; canvas.height = CH*DPR;
// ── Layout ──────────────────────────────────────────────
const OPT_Y = 68; // optical axis (top area)
const BSPC = 28; // vertical band spacing at filter
const BHW = 20; // beam half-width for 2-ray representation
// Lamp geometry
const BOX_LEFT = 5; // lamp box left edge
const BOX_RIGHT = 75; // lamp box right edge
const BOX_HALF_H = 10; // lamp box half-height
const APEX_X = 112; // cone apex (diverging source point)
const STAND_SRC = 45; // source stand x
// Component x positions
const L1_X = 168;
const GRT_X = 265;
const FLT_X = 500;
const L2_X = 615;
const OUT_X = 820;
// Lens parameters
const L1_H = 35, L1_R = 100;
const L2_H = 100, L2_R = 250;
const L1_HT = L1_R - Math.sqrt(L1_R**2 - L1_H**2); // half-thickness L1 ≈ 6.3
const L2_HT = L2_R - Math.sqrt(L2_R**2 - L2_H**2); // half-thickness L2 ≈ 47
const LENS2_CY = OPT_Y + 3*BSPC; // L2 centre = middle of band spread = 152
const OUT_Y = LENS2_CY; // output spot y = 152
const OUT_R = 24;
const RAIL_Y = 272;
const LBL_Y = 288;
function bandY(i) { return OPT_Y + i*BSPC; }
// ── Drawing helpers ─────────────────────────────────────
function ln(x1,y1,x2,y2) { ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke(); }
function lbl(text, x) {
ctx.fillStyle='#333'; ctx.font='bold 12px Arial';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(text, x, LBL_Y);
}
// ── Biconvex lens (two circular arcs, no fill) ──────────
//function biconvex(lx, cy, H, R, lw) {
// const alpha = Math.asin(H/R);
// const d = Math.sqrt(R*R - H*H);
// ctx.strokeStyle='#111'; ctx.lineWidth=lw;
// ctx.beginPath(); ctx.arc(lx-d, cy, R, -alpha, +alpha, false); ctx.stroke();
// ctx.beginPath(); ctx.arc(lx+d, cy, R, Math.PI-alpha, Math.PI+alpha, false); ctx.stroke();
//}
function biconvex(lx, cy, H, R, lw) {
const alpha = Math.asin(H / R);
const d = Math.sqrt(R * R - H * H);
ctx.beginPath();
ctx.arc(lx - d, cy, R, -alpha, +alpha, false);
ctx.arc(lx + d, cy, R, Math.PI - alpha, Math.PI + alpha, false);
ctx.closePath();
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.strokeStyle = '#111';
ctx.lineWidth = lw;
ctx.stroke();
}
// ═══════════════════════════════════════════════════════
// COMPONENT DRAW FUNCTIONS
// ═══════════════════════════════════════════════════════
// ① Source: rectangular housing + rightward cone + stand
function drawSource() {
// Box body
ctx.fillStyle='#f0f0f0';
ctx.fillRect(BOX_LEFT, OPT_Y-BOX_HALF_H, BOX_RIGHT-BOX_LEFT, BOX_HALF_H*2);
ctx.strokeStyle='#222'; ctx.lineWidth=1.8;
ctx.strokeRect(BOX_LEFT, OPT_Y-BOX_HALF_H, BOX_RIGHT-BOX_LEFT, BOX_HALF_H*2);
// Leftward truncated cone
ctx.strokeStyle='#222'; ctx.lineWidth=1.8;
ctx.beginPath();
ctx.moveTo(BOX_RIGHT, OPT_Y-BOX_HALF_H);
ctx.lineTo(APEX_X, OPT_Y-3*BOX_HALF_H);
ctx.lineTo(APEX_X, OPT_Y+3*BOX_HALF_H);
ctx.lineTo(BOX_RIGHT, OPT_Y+BOX_HALF_H);
ctx.closePath();
ctx.fillStyle = '#f0f0f0';
ctx.fill();
ctx.stroke();
// Decorative stripes
ctx.strokeStyle='#222'; ctx.lineWidth=1.8;
ctx.beginPath();
ctx.moveTo(BOX_LEFT+10, OPT_Y-BOX_HALF_H);
ctx.lineTo(BOX_LEFT+10, OPT_Y+BOX_HALF_H);
ctx.lineTo(BOX_LEFT+15, OPT_Y+BOX_HALF_H);
ctx.lineTo(BOX_LEFT+15, OPT_Y-BOX_HALF_H);
ctx.lineTo(BOX_LEFT+20, OPT_Y-BOX_HALF_H);
ctx.lineTo(BOX_LEFT+20, OPT_Y+BOX_HALF_H);
ctx.stroke();
// Stand
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(STAND_SRC, OPT_Y+BOX_HALF_H, STAND_SRC, RAIL_Y);
lbl('1', STAND_SRC);
}
// ② Lens 1 (thin collimating lens)
function drawLens1() {
biconvex(L1_X, OPT_Y, L1_H, L1_R, 2.2);
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(L1_X, OPT_Y+L1_H, L1_X, RAIL_Y);
lbl('2', L1_X);
}
// ③ Grating (thin ruled rectangle centred on OPT_Y)
function drawGrating() {
const H=70, W=8, N=11;
ctx.fillStyle = '#ffffff';
ctx.fillRect(GRT_X - W/2, OPT_Y - H/2, W, H);
ctx.strokeStyle='rgba(80,80,180,0.35)'; ctx.lineWidth=0.7;
for(let k=0;k<=N;k++) ln(GRT_X-W/2, OPT_Y-H/2+k*H/N, GRT_X+W/2, OPT_Y-H/2+k*H/N);
ctx.strokeStyle='#222'; ctx.lineWidth=1.8; ctx.strokeRect(GRT_X-W/2, OPT_Y-H/2, W, H);
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(GRT_X, OPT_Y+H/2, GRT_X, RAIL_Y);
lbl('3', GRT_X);
}
// ⑤ Filter (coloured segments, opacity = transmission)
function drawFilter() {
const segH = BSPC*0.82;
const topY = bandY(0)-segH/2-2, totH = bandY(6)-bandY(0)+segH+4;
ctx.fillStyle='#eee'; ctx.fillRect(FLT_X-5, topY, 10, totH);
for(let i=0;i<7;i++){
const cy=bandY(i), v=sv[i], a=v<0.02?0.07:v;
ctx.fillStyle=rgba(BANDS[i].hex,a);
ctx.fillRect(FLT_X-5, cy-segH/2, 10, segH);
ctx.strokeStyle=rgba(BANDS[i].hex,Math.max(0.3,v));
ctx.lineWidth=0.7; ctx.strokeRect(FLT_X-5, cy-segH/2, 10, segH);
}
ctx.strokeStyle='#222'; ctx.lineWidth=1.8; ctx.strokeRect(FLT_X-5, topY, 10, totH);
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(FLT_X, topY+totH, FLT_X, RAIL_Y);
lbl('5', FLT_X);
}
// ⑥ Lens 2 (large, prominent biconvex)
function drawLens2() {
biconvex(L2_X, LENS2_CY, L2_H, L2_R, 2.5);
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(L2_X, LENS2_CY+L2_H, L2_X, RAIL_Y);
lbl('6', L2_X);
}
// ⑦ Output spot
function drawOutputSpot() {
const c=mixColors(), hex=toHex(c);
ctx.beginPath(); ctx.arc(OUT_X, OUT_Y, OUT_R, 0, 2*Math.PI);
ctx.fillStyle=hex; ctx.fill();
ctx.strokeStyle='#111'; ctx.lineWidth=2.5; ctx.stroke();
ctx.strokeStyle='#888'; ctx.lineWidth=1.3;
ln(OUT_X, OUT_Y+OUT_R, OUT_X, RAIL_Y);
lbl('7', OUT_X);
}
function drawRail() {
ctx.strokeStyle='#bbb'; ctx.lineWidth=2;
ln(12, RAIL_Y, CW-12, RAIL_Y);
}
// ═══════════════════════════════════════════════════════
// MAIN REDRAW
// ═══════════════════════════════════════════════════════
function redraw() {
ctx.save();
ctx.scale(DPR, DPR);
ctx.fillStyle='#ffffff'; ctx.fillRect(0,0,CW,CH);
const L1_RIGHT = L1_X + L1_HT; // ≈174
const L2_LEFT = L2_X - L2_HT; // ≈568
const L2_RIGHT = L2_X + L2_HT; // ≈662
// ── 1. DIVERGING RAYS: cone apex → L1 (3 rays from point source) ──
ctx.setLineDash([]);
// Two outer rays showing divergence
ctx.strokeStyle = 'rgba(0,0,0,0.9)';
ctx.lineWidth = 5;
ln(APEX_X, OPT_Y, L1_X, OPT_Y-BHW*1.0);
ln(APEX_X, OPT_Y, L1_X, OPT_Y+BHW*1.0);
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(255,255,255,1)';
ctx.lineWidth = 3;
ln(APEX_X, OPT_Y, L1_X, OPT_Y-BHW*1.0);
ln(APEX_X, OPT_Y, L1_X, OPT_Y+BHW*1.0);
// Central ray
ctx.strokeStyle = 'rgba(0,0,0,0.9)';
ctx.lineWidth = 5;
ctx.setLineDash([]);
ln(APEX_X, OPT_Y, L1_X, OPT_Y);
ctx.setLineDash([]);
ctx.strokeStyle = 'rgba(255,255,255,1)';
ctx.lineWidth = 3;
ln(APEX_X, OPT_Y, L1_X, OPT_Y);
// ── 2. COLLIMATED BEAM: L1 → grating (3 parallel lines) ──
ctx.setLineDash([]);
// outer dark stroke
ctx.strokeStyle = 'rgba(0,0,0,0.9)';
ctx.lineWidth = 5;
ln(L1_RIGHT, OPT_Y-BHW, GRT_X, OPT_Y-BHW);
ln(L1_RIGHT, OPT_Y, GRT_X, OPT_Y );
ln(L1_RIGHT, OPT_Y+BHW, GRT_X, OPT_Y+BHW);
// inner white stroke
ctx.strokeStyle = 'rgba(255,255,255,1)';
ctx.lineWidth = 3;
ln(L1_RIGHT, OPT_Y-BHW, GRT_X, OPT_Y-BHW);
ln(L1_RIGHT, OPT_Y, GRT_X, OPT_Y );
ln(L1_RIGHT, OPT_Y+BHW, GRT_X, OPT_Y+BHW);
ctx.setLineDash([]);
// ── 3. FAN: grating → filter (3 lines per colour) ──
for(let i=0;i<7;i++){
const a = Math.max(0.12, sv[i]*0.9);
ctx.strokeStyle = rgba(BANDS[i].hex, a);
ctx.lineWidth = 1.5;
ln(GRT_X, OPT_Y-BHW, FLT_X, bandY(i));
ln(GRT_X, OPT_Y+BHW, FLT_X, bandY(i));
ln(GRT_X, OPT_Y, FLT_X, bandY(i));
}
// ── 4. HORIZONTAL: filter → L2 left face ──
for(let i=0;i<7;i++){
if(sv[i]<0.005) continue;
ctx.strokeStyle = rgba(BANDS[i].hex, sv[i]*0.85);
ctx.lineWidth = 1.5;
ln(FLT_X+5, bandY(i), (L2_LEFT+0.5*(L2_RIGHT-L2_LEFT)), bandY(i));
}
// ── 5. CONVERGING: L2 right face → output spot ──
for(let i=0;i<7;i++){
if(sv[i]<0.005) continue;
ctx.strokeStyle = rgba(BANDS[i].hex, sv[i]*0.85);
ctx.lineWidth = 1.5;
ln((L2_LEFT+0.5*(L2_RIGHT-L2_LEFT)), bandY(i), OUT_X-OUT_R, OUT_Y);
}
// ── 6. ④ fan label ──
ctx.fillStyle='#555'; ctx.font='bold 12px Arial';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('4', (GRT_X+FLT_X)/2, LBL_Y);
// ── 7. COMPONENTS drawn over rays ──
drawRail();
drawSource();
drawLens1();
drawGrating();
drawFilter();
drawLens2();
drawOutputSpot();
ctx.restore();
}
// ═══════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════
buildSliders();
buildPresets();
updateOutput();
redraw();
</script>
</body>
</html>