test Nabi

<!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>&ensp;
        <span class="cr-lbl">G</span><span id="g-val">255</span>&ensp;
        <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>