PageSourceSearch

https://fermi.ai/hp-m-assets/beat-sim.js

js fermi.ai collected 2026-10-05 11:59:55 UTC 51,229 bytes, 1,072 lines download raw bytes

1/* Beat 3. Two pieces in one closure: the simulation's own script, verbatim but for the
2   fixes listed in beat_sim_build.py, and the beat driver under it. Generated file: edit
3   beat-sim.src.js and re-run beat_sim_build.py.
4
5   INLINE THIS AFTER beat-sim.html IN DOCUMENT ORDER. The simulation's script mounts on
6   its own root as it parses, and it throws if the markup is not in the document yet.
7   Then call BeatSim.init(root). */
8(function(){
9/* beat-sim fix B3: the mass and the x(t) trace take the page's own --accent, read off
10   #beatSim rather than hardcoded, so they stay the blue the word "Physics" is set in. Cached
11   only once it has actually been resolved, so a call made before the fragment is in the
12   document does not freeze the fallback. */
13var __beatHue = null;
14function beatHue(){
15  if(__beatHue) return __beatHue;
16  var el = document.getElementById('beatSim'), v = '';
17  try{ if(el) v = getComputedStyle(el).getPropertyValue('--bs-trace').trim(); }catch(e){}
18  if(/^#[0-9a-fA-F]{6}$/.test(v)){ __beatHue = v; return v; }
19  return '#8AA3ED';
20}
21/* ============================================================================
22   SHELL RUNTIME — emit VERBATIM. The SIM provides onFrame/onReset/onResize.
23   ========================================================================== */
24const Shell = (function(){
25  const root = document.getElementById('shell');
26  let onFrame=()=>{}, onReset=()=>{}, onResize=()=>{}, onStep=()=>{}, onComplete=()=>{};
27  let playing=true, speed=1, last=performance.now(), started=false;
28  let inqStep=0, inqTotal=0;   // guided-inquiry stepper (shell-owned)
29
30  function loop(now){
31    let dt=(now-last)/1000; if(dt>0.1)dt=0.1; last=now;
32    if(playing) onFrame(dt*speed);
33    requestAnimationFrame(loop);
34  }
35  function refit(){ onResize(); last=performance.now(); }
36
37  function setPlaying(p){
38    playing=p; last=performance.now();
39    const b=document.getElementById('shell-play');
40    if(b){ b.textContent = p ? '⏸ Pause' : '▶ Play'; b.classList.toggle('paused', !p); }
41  }
42  function setMaximized(on){
43    root.classList.toggle('shell-max', on);
44    const maxBtn=document.getElementById('shell-maximize');
45    if(maxBtn){
46      maxBtn.classList.toggle('active', on);
47      maxBtn.textContent = on ? '⤡ Minimize' : '⛶ Maximize';
48    }
49    requestAnimationFrame(refit);
50  }
51
52  // Dynamic sidebar: collapse the guided-inquiry zone to its restore bar (on
53  // answer or Skip) or expand it again (restore-bar click). Refit canvases so
54  // the sidebar height change never distorts the hero.
55  function setInquiryCollapsed(on){
56    root.classList.toggle('inquiry-collapsed', on);
57    requestAnimationFrame(refit);
58  }
59  function setLectureMode(on){
60    root.classList.toggle('lecture-mode', on);
61    const b = document.getElementById('shell-lecture');
62    if(b) b.classList.toggle('active', on);
63    if(on){
64      const cards = inqCards();
65      if(cards.length) inqShow(cards.length - 1); // jump to last step → onStep(last) paints the complete scene
66      setInquiryCollapsed(true);                  // hide inquiry → restore bar
67    } else {
68      setInquiryCollapsed(false);
69      inqShow(0);                                 // re-initiate guided inquiry from step 1
70    }
71  }
72
73  // ---- Guided-inquiry stepper (shell-owned, so Next/Finish are always wired) ----
74  // The SIM supplies sequential <div class="inq-step"> cards in #inq-cards. The
75  // shell shows ONE at a time, builds the dots, and drives the Next/Finish button.
76  // A card marked data-gate keeps Next disabled until the student clicks a .choice
77  // inside it (auto-detected) or the SIM calls Shell.stepReady().
78  function inqCards(){ return root.querySelectorAll('#inq-cards .inq-step'); }
79  function inqUpdatePager(){
80    const cards=inqCards();
81    const prev=document.getElementById('inq-prev');
82    const pnext=document.getElementById('inq-pager-next');
83    if(prev) prev.disabled = inqStep<=0;
84    if(pnext) pnext.disabled = inqStep>=cards.length-1;
85  }
86  function inqPrev(){
87    if(inqStep>0) inqShow(inqStep-1);
88  }
89  function inqPagerNext(){
90    const cards=inqCards();
91    if(inqStep<cards.length-1) inqShow(inqStep+1);
92  }
93  function inqShow(n){
94    const cards=inqCards();
95    if(!cards.length) return;
96    inqStep=Math.max(0,Math.min(n,cards.length-1));
97    cards.forEach((c,i)=>c.classList.toggle('active',i===inqStep));
98    root.querySelectorAll('#inq-dots .inq-dot').forEach((d,i)=>{
99      d.classList.toggle('active',i===inqStep);
100      d.classList.toggle('done',i<inqStep);
101    });
102    const next=document.getElementById('inq-next');
103    if(next){
104      const card=cards[inqStep];
105      const gated = card && card.hasAttribute('data-gate') && !card.hasAttribute('data-ready');
106      next.disabled = gated;
107      next.textContent = (inqStep>=cards.length-1) ? 'Finish' : 'Next →';
108    }
109    inqUpdatePager();
110    onStep(inqStep);
111    requestAnimationFrame(refit);
112  }
113  function inqNext(){
114    const cards=inqCards();
115    if(inqStep>=cards.length-1) finishInquiry();  // Finish = jump to completed state + hide the inquiry
116    else inqShow(inqStep+1);
117  }
118  // Finish OR Skip: fast-forward through any remaining steps (applying each
119  // onStep so the scene ends in the SAME fully-revealed state as completing the
120  // inquiry), let the SIM set up free exploration via onComplete(), collapse the
121  // zone, and leave the sim live so the student can play with every control.
122  function finishInquiry(){
123    const cards=inqCards();
124    if(cards.length){
125      for(let k=inqStep+1;k<cards.length;k++) onStep(k);
126      inqStep=cards.length-1;
127      root.querySelectorAll('#inq-dots .inq-dot').forEach((d,i)=>{
128        d.classList.toggle('active',i===inqStep);
129        d.classList.toggle('done',i<inqStep);
130      });
131    }
132    onComplete();
133    setInquiryCollapsed(true);
134    setPlaying(true);
135  }
136  // SIM may call this to satisfy a data-gate step's requirement (e.g. "watched").
137  function stepReady(){
138    const card=inqCards()[inqStep];
139    if(card) card.setAttribute('data-ready','');
140    const next=document.getElementById('inq-next'); if(next) next.disabled=false;
141  }
142
143  function wire(){
144    const play=document.getElementById('shell-play');
145    const reset=document.getElementById('shell-reset');
146    const spd=document.getElementById('shell-speed');
147    const maxBtn=document.getElementById('shell-maximize');
148    const restore=document.getElementById('aside-inquiry-restore');
149    const tFormal=document.getElementById('toggle-formal');
150
151    if(play) play.addEventListener('click',()=>setPlaying(!playing));
152    // Reset zeroes state AND ensures the loop is running (replay-from-start).
153    if(reset) reset.addEventListener('click',()=>{ onReset(); last=performance.now(); setPlaying(true); });
154    if(spd) spd.addEventListener('change',e=>{ speed=parseFloat(e.target.value)||1; });
155
156    if(maxBtn) maxBtn.addEventListener('click',()=>setMaximized(!root.classList.contains('shell-max')));
157
158    const infoBtn=document.getElementById('shell-info');
159    const infoModal=document.getElementById('shell-info-modal');
160    const infoClose=document.getElementById('shell-info-close');
161    function setInfo(open){ if(infoModal) infoModal.classList.toggle('open',open); }
162    if(infoBtn) infoBtn.addEventListener('click',()=>setInfo(true));
163    if(infoClose) infoClose.addEventListener('click',()=>setInfo(false));
164    if(infoModal) infoModal.addEventListener('click',e=>{ if(e.target===infoModal) setInfo(false); });
165
166    // Light/dark theme toggle (default dark). Swaps the icon and refits canvases.
167    const themeBtn=document.getElementById('shell-theme');
168    if(themeBtn) themeBtn.addEventListener('click',()=>{
169      const light=document.body.classList.toggle('light-theme');
170      themeBtn.textContent = light ? '☀' : '☾';
171      requestAnimationFrame(refit);
172    });
173    const lecture = document.getElementById('shell-lecture');
174    if(lecture) lecture.addEventListener('click', ()=> setLectureMode(!root.classList.contains('lecture-mode')));
175
176    document.addEventListener('keydown',e=>{
177      if(e.key!=='Escape') return;
178      if(infoModal && infoModal.classList.contains('open')) setInfo(false);
179      else if(root.classList.contains('shell-max')) setMaximized(false);
180    });
181
182    // Restore bar exits lecture mode and re-initiates inquiry from step 1.
183    if(restore) restore.addEventListener('click',()=> setLectureMode(false));
184    // Next / Finish — shell-driven step advance (Finish collapses the inquiry).
185    const inqNextBtn=document.getElementById('inq-next');
186    if(inqNextBtn) inqNextBtn.addEventListener('click',inqNext);
187    const inqPrevBtn=document.getElementById('inq-prev');
188    if(inqPrevBtn) inqPrevBtn.addEventListener('click',inqPrev);
189    const inqPagerNextBtn=document.getElementById('inq-pager-next');
190    if(inqPagerNextBtn) inqPagerNextBtn.addEventListener('click',inqPagerNext);
191    // Auto-gate: a .choice click inside the active gated card enables Next.
192    const cardsHost=document.getElementById('inq-cards');
193    if(cardsHost) cardsHost.addEventListener('click',e=>{
194      const ch=e.target.closest('.choice'); if(!ch) return;
195      const card=ch.closest('.inq-step');
196      if(card && card.classList.contains('active')) stepReady();
197    });
198    if(tFormal) tFormal.addEventListener('click',()=>{
199      const hidden=root.classList.toggle('hide-formal');
200      tFormal.classList.toggle('active',!hidden);
201      requestAnimationFrame(refit);
202    });
203
204    root.querySelectorAll('.shell-panel-head').forEach(head=>{
205      head.addEventListener('click',()=>{
206        head.parentElement.classList.toggle('collapsed');
207        requestAnimationFrame(refit);
208      });
209    });
210
211    let rt; window.addEventListener('resize',()=>{ clearTimeout(rt); rt=setTimeout(refit,80); });
212  }
213
214  return {
215    init(opts){
216      onFrame=opts.onFrame||onFrame; onReset=opts.onReset||onReset; onResize=opts.onResize||onResize; onStep=opts.onStep||onStep; onComplete=opts.onComplete||onComplete;
217      wire();
218      // Build the guided-inquiry stepper from the .inq-step cards the SIM supplied.
219      const cards=inqCards();
220      inqTotal=cards.length;
221      if(inqTotal>0){
222        const dots=document.getElementById('inq-dots');
223        if(dots){ dots.innerHTML=''; for(let i=0;i<inqTotal;i++){ const d=document.createElement('span'); d.className='inq-dot'; dots.appendChild(d); } }
224        inqShow(0);
225      } else {
226        root.classList.add('no-inquiry');   // no inquiry → hide the whole zone + restore bar
227      }
228      onResize(); onReset();
229      last=performance.now();
230      if(!started){ started=true; requestAnimationFrame(loop); }
231    },
232    get speed(){return speed;}, get playing(){return playing;},
233    // Guided-inquiry step (current 0-based index). The SIM reads it / its onStep
234    // callback to evolve the scene as the student advances.
235    get step(){return inqStep;}, get totalSteps(){return inqTotal;},
236    // SIM calls this to satisfy a data-gate step (e.g. after the student has watched).
237    stepReady,
238    setPlaying, refit
239  };
240})();
241
242/* ============================================================================
243   SIM — the damped oscillator: exact closed-form physics + drawing + pedagogy.
244   ========================================================================== */
245(function(){
246'use strict';
247
248/* ---------------- exact physics (matches the math kernel) ---------------- */
249const W0=3, X0=1, EPS=0.02, TW=8;   // ω0 [rad/s], x0 [m], rest band [m], default chart span [s]
250const SPAN_MIN=8, SPAN_MAX=20;         // zoom-in floor = default 0–8 s; zoom-out ceiling = 0–20 s
251const view={ t0:0, span:TW };         // always anchored at t = 0
252let chartGeom=null;                   // last chart pixel bounds (for wheel zoom)
253
254function resetView(){ view.t0=0; view.span=TW; }
255function niceTimeStep(span){
256  if(span<=10) return 1;
257  if(span<=25) return 2;
258  if(span<=50) return 5;
259  if(span<=100) return 10;
260  return 20;
261}
262
263function xOfT(a,t){
264  if(t<=0) return X0;
265  if(Math.abs(a-W0)<1e-9) return X0*(1+W0*t)*Math.exp(-W0*t);              // critical
266  if(a<W0){                                                                 // underdamped
267    const wd=Math.sqrt(W0*W0-a*a);
268    return X0*Math.exp(-a*t)*(Math.cos(wd*t)+(a/wd)*Math.sin(wd*t));
269  }
270  const s=Math.sqrt(a*a-W0*W0), r1=-a+s, r2=-a-s;                           // overdamped
271  const c1=-r2/(r1-r2), c2=r1/(r1-r2);
272  return X0*(c1*Math.exp(r1*t)+c2*Math.exp(r2*t));
273}
274function slowRate(a){ return a-Math.sqrt(Math.max(0,a*a-W0*W0)); }          // λ, peaks at α=ω0
275function regimeOf(a){ return Math.abs(a-W0)<1e-6?'critical':(a<W0?'under':'over'); }
276
277const settleCache=new Map();
278function settleTime(a){                       // last time |x| ≥ 0.02 m (2% band), exact scan
279  const k=a.toFixed(4);
280  if(settleCache.has(k)) return settleCache.get(k);
281  let out;
282  if(a<1e-6) out=Infinity;
283  else{
284    const T=130; let hi=-1;
285    for(let t=T;t>=0;t-=0.05){ if(Math.abs(xOfT(a,t))>=EPS){ hi=t; break; } }
286    if(hi<0) out=0;
287    else{
288      out=hi;
289      for(let t=hi+0.05;t>=hi;t-=0.001){ if(Math.abs(xOfT(a,t))>=EPS){ out=t; break; } }
290    }
291  }
292  settleCache.set(k,out); return out;
293}
294
295/* ---------------- state ---------------- */
296const S={ mode:'single', t:0, alpha:0.6, predicted:null, evaluated:false, _lastMsg:null };
297
298/* ---------------- discovery tracking (pedagogy C1/D7, F3/F5) ---------------- */
299let discoveredReadouts=false;
300let discoveredCritical=false;
301let enteredRace=false;
302
303function revealReadouts(){
304  if(discoveredReadouts) return;
305  discoveredReadouts=true;
306  document.querySelectorAll('.not-discovered').forEach(el=>el.classList.remove('not-discovered'));
307}
308function revealCritical(){
309  if(discoveredCritical) return;
310  discoveredCritical=true;
311  const gc=document.getElementById('go-critical');
312  if(gc) gc.classList.remove('go-critical-hidden');
313}
314/* ---------------- DOM ---------------- */
315const $=id=>document.getElementById(id);
316const stage=$('bs-stage'), sctx=stage.getContext('2d');
317const lamC=$('lamCanvas'), lctx=lamC.getContext('2d');
318const slider=$('alpha'), alphaR=$('alpha-readout'), regBadge=$('regime-badge');
319const lamR=$('lambda-readout');
320const strip=$('race-strip'), stripMsg=$('strip-msg'), stripChoices=$('strip-choices');
321const evalBox=$('predict-eval');
322const btnSingle=$('mode-single'), btnRace=$('mode-race');
323
324/* Wheel zoom on the time axis — always 0 → span.
325   Scroll in → closer (down to 0–8 s). Scroll out → wider (up to 0–20 s). */
326/* beat-sim fix B1: the wheel zoom is dropped, the page owns the scroll */
327
328/* ---------------- helpers ---------------- */
329function fit(canvas,ctx){
330  const dpr=Math.min(3, window.devicePixelRatio||1)   /* beat-sim fix 3: bound the backing store */;
331  const r=canvas.getBoundingClientRect();
332  const w=Math.floor(r.width*dpr), h=Math.floor(r.height*dpr);
333  if(canvas.width!==w||canvas.height!==h){canvas.width=w;canvas.height=h;}
334  ctx.setTransform(1,0,0,1,0,0); ctx.scale(dpr,dpr);
335  return {w:r.width, h:r.height};
336}
337function hexA(hex,al){ const n=parseInt(hex.slice(1),16);
338  return 'rgba('+((n>>16)&255)+','+((n>>8)&255)+','+(n&255)+','+al+')'; }
339function rr(c,x,y,ww,hh,r){ c.beginPath(); c.moveTo(x+r,y);
340  c.arcTo(x+ww,y,x+ww,y+hh,r); c.arcTo(x+ww,y+hh,x,y+hh,r);
341  c.arcTo(x,y+hh,x,y,r); c.arcTo(x,y,x+ww,y,r); c.closePath(); }
342function txtC(c,W,H,s,x,y,o){ o=o||{};
343  c.font=(o.bold?'600 ':'')+(o.size||12)+"px Inter,-apple-system,'Segoe UI',sans-serif";
344  const tw=c.measureText(s).width;
345  let lx=o.align==='right'?x-tw:o.align==='center'?x-tw/2:x;
346  lx=Math.max(3,Math.min(lx,W-tw-3));
347  let ly=Math.max(11,Math.min(y,H-3));
348  if(o.bg){ c.fillStyle=o.bg; c.fillRect(lx-3,ly-(o.size||12),tw+6,(o.size||12)+5); }
349  c.fillStyle=o.col||'#e2e8f0'; c.textAlign='left'; c.textBaseline='alphabetic';
350  c.fillText(s,lx,ly);
351}
352function drawSpring(c,cx,y0,y1,amp,col){
353  c.strokeStyle=col; c.lineWidth=1.8; c.lineJoin='round';
354  c.beginPath();
355  const L=y1-y0;
356  if(L<9){ c.moveTo(cx,y0); c.lineTo(cx,Math.max(y0,y1)); c.stroke(); return; }
357  const lead=Math.min(7,L*0.12), n=9, seg=(L-2*lead)/n;
358  c.moveTo(cx,y0); c.lineTo(cx,y0+lead);
359  for(let i=0;i<n;i++){ c.lineTo(cx+((i%2)?-amp:amp), y0+lead+seg*(i+0.5)); }
360  c.lineTo(cx,y1-lead); c.lineTo(cx,y1); c.stroke();
361}
362function drawLegend(c,W,H,x1,y0,rows){
363  c.font="12px Inter,-apple-system,sans-serif";
364  let mw=0; rows.forEach(r0=>{ mw=Math.max(mw,c.measureText(r0.s).width); });
365  const bw=mw+38, rh=17, bh=rows.length*rh+10, x0=x1-bw;
366  c.fillStyle='rgba(8,9,12,0.78)'; c.strokeStyle='rgba(148,163,184,0.2)'; c.lineWidth=1;
367  rr(c,x0,y0,bw,bh,6); c.fill(); c.stroke();
368  rows.forEach((r0,i)=>{
369    const yy=y0+8+rh*i+6;
370    c.strokeStyle=r0.c; c.lineWidth=2;
371    if(r0.dash) c.setLineDash([4,3]);
372    c.beginPath(); c.moveTo(x0+8,yy-4); c.lineTo(x0+24,yy-4); c.stroke(); c.setLineDash([]);
373    c.fillStyle='#cbd5e1'; c.fillText(r0.s,x0+30,yy);
374  });
375}
376
377/* ---------------- lanes ---------------- */
378function lanes(){
379  if(S.mode==='single') return [{label:'', a:S.alpha, color:beatHue()}];
380  return [
381    {label:'underdamped', a:0.6, color:'#38bdf8'},
382    {label:'critical', a:3,   color:'#34d399'},
383    {label:'overdamped', a:S.alpha, color:'#f59e0b'}
384  ];
385}
386
387/* ---------------- main drawing ---------------- */
388function draw(){
389  const {w,h}=fit(stage,sctx);
390  if(w<60||h<60) return;
391  sctx.clearRect(0,0,w,h);
392  const txt=(s,x,y,o)=>txtC(sctx,w,h,s,x,y,o);
393
394  const plotTop=12, plotBottom=h-30, H=plotBottom-plotTop;
395  const cy=(plotTop+plotBottom)/2, scaleY=H/2/1.22;
396  const yOf=x=>cy-x*scaleY;
397  const rulerX=46;
398  const L=lanes(), nL=L.length;
399  const laneW=S.mode==='single'?112:Math.min(88,Math.max(56,(w*0.36)/3));
400  const rigW=laneW*nL;
401  const chartX0=rulerX+rigW+18, chartX1=w-12, chartW=chartX1-chartX0;
402  if(chartW<80) return;
403  /* Fixed origin + zoomable span — never slide the window with S.t (that dragged the
404     rest flag leftward). Zoom out to see a longer range; zoom in for detail. */
405  const tStart=view.t0, tSpan=view.span, tRight=tStart+tSpan;
406  const tToPx=tt=>chartX0+((tt-tStart)/tSpan)*chartW;
407  chartGeom={ chartX0, chartX1, chartW, tStart, tSpan };
408  const tDrawEnd=Math.min(S.t, tRight);
409  const tipInView=S.t>=tStart-1e-9 && S.t<=tRight+1e-9;
410  const tipPx=tipInView ? tToPx(S.t) : (S.t>tRight ? chartX1 : chartX0);
411
412  /* rest band + gridlines + equilibrium */
413  sctx.fillStyle='rgba(52,211,153,0.12)';
414  sctx.fillRect(rulerX,yOf(EPS),chartX1-rulerX,yOf(-EPS)-yOf(EPS));
415  sctx.strokeStyle='rgba(203,213,225,0.28)'; sctx.lineWidth=1;
416  [1,0.5,-0.5,-1].forEach(v=>{ sctx.beginPath(); sctx.moveTo(rulerX,yOf(v)); sctx.lineTo(chartX1,yOf(v)); sctx.stroke(); });
417  sctx.setLineDash([5,4]); sctx.strokeStyle='rgba(226,232,240,0.7)';
418  sctx.beginPath(); sctx.moveTo(rulerX,yOf(0)); sctx.lineTo(chartX1,yOf(0)); sctx.stroke(); sctx.setLineDash([]);
419  txt('x = 0 (equilibrium)',chartX0+6,yOf(0)+17,{col:'#e2e8f0',bg:'rgba(8,9,12,0.72)'});
420
421  /* left ruler (fixed SI scale — never rescaled) */
422  [[1,'+1'],[0.5,'+0.5'],[0,'0'],[-0.5,'−0.5'],[-1,'−1']].forEach(p=>{
423    sctx.strokeStyle='rgba(226,232,240,0.75)';
424    sctx.beginPath(); sctx.moveTo(rulerX-6,yOf(p[0])); sctx.lineTo(rulerX,yOf(p[0])); sctx.stroke();
425    txt(p[1],rulerX-9,yOf(p[0])+4,{align:'right',col:'#f1f5f9',bold:true});
426  });
427  txt('x (m)',4,plotTop+10,{col:'#cbd5e1',bold:true});
428
429  /* ceiling */
430  const yCeil=plotTop+8;
431  sctx.strokeStyle='rgba(226,232,240,0.75)'; sctx.lineWidth=2;
432  sctx.beginPath(); sctx.moveTo(rulerX+4,yCeil); sctx.lineTo(rulerX+rigW-4,yCeil); sctx.stroke();
433  sctx.lineWidth=1; sctx.strokeStyle='rgba(203,213,225,0.45)';
434  for(let hx=rulerX+8;hx<rulerX+rigW-4;hx+=9){
435    sctx.beginPath(); sctx.moveTo(hx,yCeil); sctx.lineTo(hx-6,yCeil-7); sctx.stroke();
436  }
437
438  /* rigs: spring + mass per lane */
439  L.forEach((ln,i)=>{
440    const cx=rulerX+laneW*(i+0.5);
441    const x=xOfT(ln.a,S.t);
442    const st=settleTime(ln.a);
443    const settled=isFinite(st)&&S.t>=st;
444    const mw=S.mode==='single'?54:34, mh=S.mode==='single'?38:26;
445    const my=yOf(x);
446    /* guide from mass to trace head (same y — the trace IS the mass height) */
447    sctx.setLineDash([2,4]); sctx.strokeStyle=hexA(ln.color,0.25); sctx.lineWidth=1;
448    sctx.beginPath(); sctx.moveTo(cx+mw/2+4,my); sctx.lineTo(tipPx,my); sctx.stroke(); sctx.setLineDash([]);
449    drawSpring(sctx,cx,yCeil,my-mh/2,Math.min(14,laneW*0.16),'rgba(203,213,225,0.8)');
450    sctx.fillStyle=ln.color;
451    rr(sctx,cx-mw/2,my-mh/2,mw,mh,6); sctx.fill();
452    sctx.strokeStyle='rgba(8,9,12,0.65)'; sctx.lineWidth=1.5; sctx.stroke();
453    if(settled){
454      sctx.strokeStyle='#34d399'; sctx.lineWidth=2.5;
455      rr(sctx,cx-mw/2-3,my-mh/2-3,mw+6,mh+6,8); sctx.stroke();
456      txt('✓',cx,my+mh/2+15,{align:'center',col:'#34d399',bold:true,bg:'rgba(8,9,12,0.6)'});
457    }
458  });
459
460  /* settle-race lane labels under the three masses */
461  if(S.mode==='race'){
462    const boxH=22, boxY=plotBottom-boxH-2;
463    sctx.font='600 12px Inter,-apple-system,sans-serif';
464    sctx.textAlign='center'; sctx.textBaseline='middle';
465    L.forEach((ln,i)=>{
466      const cx=rulerX+laneW*(i+0.5);
467      sctx.fillStyle=ln.color;
468      sctx.fillText(ln.label, cx, boxY+boxH/2);
469    });
470  }
471
472  /* chart frame + time grid (tick step grows when zoomed out) */
473  sctx.strokeStyle='rgba(203,213,225,0.4)'; sctx.lineWidth=1;
474  sctx.strokeRect(chartX0,plotTop,chartW,H);
475  const tStep=niceTimeStep(tSpan);
476  const tFirst=Math.ceil(tStart/tStep)*tStep;
477  for(let s0=tFirst; s0<=tRight+1e-9; s0+=tStep){
478    const px=tToPx(s0); if(px<chartX0-1||px>chartX1+1) continue;
479    sctx.strokeStyle='rgba(203,213,225,0.22)';
480    sctx.beginPath(); sctx.moveTo(px,plotTop); sctx.lineTo(px,plotBottom); sctx.stroke();
481    txt(s0+' s',px,plotBottom+17,{align:'center',col:'#e2e8f0',bold:true});
482  }
483
484  /* traces (exact solutions), clipped to the chart */
485  sctx.save(); sctx.beginPath(); sctx.rect(chartX0,plotTop,chartW,H); sctx.clip();
486  const steps=Math.max(2,Math.floor(chartW/2));
487  const tLo=tStart, tHi=Math.max(tLo, tDrawEnd);
488  L.forEach(ln=>{
489    sctx.strokeStyle=ln.color; sctx.lineWidth=S.mode==='single'?2.2:2;
490    sctx.beginPath();
491    for(let i=0;i<=steps;i++){
492      const tt=tLo+(tHi-tLo)*i/steps;
493      const px=tToPx(tt), py=yOf(xOfT(ln.a,tt));
494      if(i) sctx.lineTo(px,py); else sctx.moveTo(px,py);
495    }
496    sctx.stroke();
497    if(tipInView){
498      sctx.fillStyle=ln.color;
499      sctx.beginPath(); sctx.arc(tToPx(S.t),yOf(xOfT(ln.a,S.t)),3.5,0,6.2832); sctx.fill();
500    }
501  });
502  sctx.restore();
503
504  /* settle-time flags — pinned in time; only drawn while inside the view */
505  L.forEach((ln,i)=>{
506    const st=settleTime(ln.a);
507    if(!isFinite(st)||S.t<st||st<tStart-0.001||st>tRight+0.001) return;
508    const px=tToPx(st), fy=yOf(0)-14-15*i;
509    sctx.strokeStyle=hexA(ln.color,0.9); sctx.lineWidth=1.5;
510    sctx.beginPath(); sctx.moveTo(px,yOf(EPS)); sctx.lineTo(px,fy+4); sctx.stroke();
511    sctx.fillStyle=ln.color;
512    sctx.beginPath(); sctx.arc(px,yOf(xOfT(ln.a,st)),3,0,6.2832); sctx.fill();
513    txt('✓',px+5,fy,{col:'#34d399',bold:true,bg:'rgba(8,9,12,0.6)'});
514  });
515
516  /* scroll / zoom hint — bottom-left of the chart */
517  /* beat-sim fix B2: the scroll-to-zoom chip is dropped with the affordance */
518
519
520  /* legend */
521  let rows=null;
522  if(S.mode==='race'){
523    rows=L.map(ln=>({c:ln.color,s:ln.label}));
524  }
525  if(rows) drawLegend(sctx,w,h,chartX1-6,plotTop+6,rows);
526}
527
528/* ---------------- λ(α) map (aside, collapsed by default) ---------------- */
529function drawLam(){
530  const r=lamC.getBoundingClientRect();
531  if(r.width<30||r.height<30) return;
532  const {w,h}=fit(lamC,lctx);
533  lctx.clearRect(0,0,w,h);
534  const mL=36,mR=10,mT=16,mB=26;
535  const X=a=>mL+(a/6)*(w-mL-mR), Y=l=>h-mB-(l/3.3)*(h-mT-mB);
536  lctx.fillStyle='rgba(6,182,212,0.06)';  lctx.fillRect(X(0),mT,X(3)-X(0),h-mT-mB);
537  lctx.fillStyle='rgba(245,158,11,0.06)'; lctx.fillRect(X(3),mT,X(6)-X(3),h-mT-mB);
538  lctx.strokeStyle='rgba(203,213,225,0.5)'; lctx.lineWidth=1;
539  lctx.beginPath(); lctx.moveTo(X(0),mT); lctx.lineTo(X(0),h-mB); lctx.lineTo(X(6),h-mB); lctx.stroke();
540  [[0,'0'],[3,'3']].forEach(p=>{
541    lctx.beginPath(); lctx.moveTo(X(0)-4,Y(p[0])); lctx.lineTo(X(0),Y(p[0])); lctx.stroke();
542    txtC(lctx,w,h,p[1],X(0)-7,Y(p[0])+4,{align:'right',col:'#f1f5f9',bold:true});
543  });
544  [[0,'0'],[3,'ω₀ = 3'],[6,'6']].forEach(p=>{
545    lctx.beginPath(); lctx.moveTo(X(p[0]),h-mB); lctx.lineTo(X(p[0]),h-mB+4); lctx.stroke();
546    txtC(lctx,w,h,p[1],X(p[0]),h-mB+16,{align:'center',col:p[0]===3?'#6ee7b7':'#e2e8f0',bold:true});
547  });
548  txtC(lctx,w,h,'λ (rad/s)',4,12,{col:'#cbd5e1',bold:true});
549  txtC(lctx,w,h,'α (rad/s)',w-6,h-4,{align:'right',col:'#cbd5e1',bold:true});
550  lctx.setLineDash([4,3]); lctx.strokeStyle='rgba(52,211,153,0.75)';
551  lctx.beginPath(); lctx.moveTo(X(3),mT); lctx.lineTo(X(3),h-mB); lctx.stroke(); lctx.setLineDash([]);
552  lctx.strokeStyle=beatHue(); lctx.lineWidth=2; lctx.beginPath();
553  for(let a=0;a<=6.0001;a+=0.03){ const px=X(a),py=Y(slowRate(a)); if(a>0) lctx.lineTo(px,py); else lctx.moveTo(px,py); }
554  lctx.stroke();
555  lctx.fillStyle='#34d399'; lctx.beginPath(); lctx.arc(X(3),Y(3),3,0,6.2832); lctx.fill();
556  txtC(lctx,w,h,'peak: λ = ω₀',X(3)+7,Y(3)+4,{col:'#6ee7b7',bold:true});
557  const mx=X(Math.min(6,Math.max(0,S.alpha))), my=Y(slowRate(S.alpha));
558  lctx.setLineDash([3,3]); lctx.strokeStyle='rgba(255,255,255,0.45)';
559  lctx.beginPath(); lctx.moveTo(mx,my); lctx.lineTo(mx,h-mB); lctx.stroke(); lctx.setLineDash([]);
560  lctx.fillStyle='#fff'; lctx.beginPath(); lctx.arc(mx,my,4,0,6.2832); lctx.fill();
561  lctx.strokeStyle='#f59e0b'; lctx.lineWidth=2; lctx.beginPath(); lctx.arc(mx,my,6.5,0,6.2832); lctx.stroke();
562}
563
564/* ---------------- readouts ---------------- */
565function updateReadouts(){
566  alphaR.textContent='α = '+S.alpha.toFixed(2)+' rad/s';
567  const r=regimeOf(S.alpha);
568  regBadge.className='regime '+(r==='critical'?'crit':r)+(discoveredReadout
568s?'':' not-discovered');
569  regBadge.textContent=r==='under'?'UNDERDAMPED':r==='critical'?'CRITICAL α = ω₀':'OVERDAMPED';
570  regBadge.title=S.alpha<1e-9?'α = 0: no friction at all — it never settles':'regime = compare α with ω₀ = 3 rad/s';
571  if(lamR){
572    const lam=slowRate(S.alpha);
573    lamR.textContent='λ = '+lam.toFixed(2)+' rad/s';
574    lamR.classList.toggle('peak',r==='critical');
575  }
576}
577
578/* ---------------- race strip + prediction ---------------- */
579const pickPhrase={heavy:'the heavy mass wins',critical:'critical wins',tie:'dead heat'};
580
581function winnerSentence(sc,sy){
582  const ay=S.alpha, ry=slowRate(ay).toFixed(2);
583  if(!isFinite(sy)) return '⚑ <b>Critical (α = 3.00) first</b> — settled t = '+sc.toFixed(2)+' s. α = 0.00 never settles: no damping, no rest.';
584  if(Math.abs(sc-sy)<0.05) return 'Dead heat — α = '+ay.toFixed(2)+' is (numerically) critical: both settled by '+Math.max(sc,sy).toFixed(2)+' s.';
585  if(sc<sy){
586    let tail;
587    if(ay>W0){
588      tail=(S.t<sy?'Heavy α = '+ay.toFixed(2)+' is <b>still crawling</b> — settles only at '+sy.toFixed(2)+' s'
589                  :'Heavy α = '+ay.toFixed(2)+' crawled in at '+sy.toFixed(2)+' s')
590           +' (slow rate '+ry+' rad/s vs 3.00 rad/s at critical).';
591    } else tail='α = '+ay.toFixed(2)+' follows at '+sy.toFixed(2)+' s.';
592    return '⚑ <b>Critical (α = 3.00) first</b> — settled t = '+sc.toFixed(2)+' s. '+tail;
593  }
594  return 'α = '+ay.toFixed(2)+' slid into the band first (t = '+sy.toFixed(2)+' s vs '+sc.toFixed(2)+
595    ' s) — but it <b>rings</b>: it overshoots x = 0 (oscillating on, inside the band). Critical (λ = 3.00 rad/s) is the fastest that <i>never</i> overshoots; every α &gt; ω₀ is slower.';
596}
597function shortVerdict(sc,sy){
598  const tie=isFinite(sy)&&Math.abs(sc-sy)<0.05;
599  const win=tie?'tie':(sc<sy?'critical':'under');
600  if(S.predicted==='critical') return win==='critical'?'✓ Your pick — critical — wins.':win==='tie'?'~ Nearly — dead heat.':'✗ Your pick lost this run.';
601  if(S.predicted==='heavy') return win==='critical'?'✗ Heavy loses.':win==='tie'?'✗ Not heavy — a tie.':'✗ Your lane 3 is not even heavy (α < ω₀).';
602  return win==='tie'?'✓ Dead heat — as you predicted.':'✗ Not a tie.';
603}
604function longEval(sc,sy){
605  const ay=S.alpha, ry=slowRate(ay).toFixed(2);
606  const tie=isFinite(sy)&&Math.abs(sc-sy)<0.05;
607  if(S.predicted==='heavy'){
608    if(!tie&&isFinite(sy)&&sc<sy&&ay>W0)
609      return '✗ You predicted the heavy mass (α = '+ay.toFixed(2)+' rad/s). It reached rest <b>'+(sy/sc).toFixed(1)+
610        '× later</b> than critical ('+sy.toFixed(2)+' s vs '+sc.toFixed(2)+' s). "More friction ⇒ faster stop" treats damping as a pure brake — but past α = ω₀ the motion is two exponentials e<sup>r₁t</sup> + e<sup>r₂t</sup> with r = −α ± √(α² − ω₀²), and the <b>slow</b> rate λ = α − √(α² − ω₀²) sinks toward zero as α grows: here '+
611        ry+' rad/s against 3.00 rad/s at critical. Heavy damping kills the overshoot — and strangles the return itself.';
612    if(tie) return '✗ Dead heat, not a heavy win — your lane 3 sits at (numerically) critical damping.';
613    return '✗ The heavy pick lost. '+winnerSentence(sc,sy);
614  }
615  if(S.predicted==='critical'){
616    if(!tie&&isFinite(sy)&&sc<sy)
617      return '✓ Correct: critical damping (α = ω₀ = 3.00 rad/s) is the fastest return that never overshoots — settled in '+sc.toFixed(2)+
618        ' s, decay rate λ = 3.00 rad/s, the very peak of λ(α). Drag lane 3 anywhere in α &gt; ω₀ and it only gets slower.';
619    if(!isFinite(sy)) return '✓ Correct — and your lane 3 has α = 0: it never settles at all.';
620    if(tie) return '~ Dead heat: your lane 3 <i>is</i> (numerically) critical.';
621    return '~ You picked critical, the fastest <i>non-overshooting</i> return — but your lane 3 (α = '+ay.toFixed(2)+
622      ' &lt; ω₀) slid into the ±2 cm band sooner by ringing <i>inside</i> it. Among all α ≥ ω₀, critical wins outright.';
623  }
624  return tie?'✓ Dead heat — as you predicted (lane 3 ≈ critical).':'✗ Not a tie: '+sc.toFixed(2)+' s (critical) vs '+
625    (isFinite(sy)?sy.toFixed(2)+' s':'never')+' (α = '+ay.toFixed(2)+'). The settle-rate peak at α = ω₀ is sharp, not flat.';
626}
627function renderChoices(){
628  const sc=settleTime(3), sy=settleTime(S.alpha);
629  const tie=isFinite(sy)&&Math.abs(sc-sy)<0.05;
630  const win=S.evaluated?(tie?'tie':(sc<sy?'critical':null)):null;
631  document.querySelectorAll('[data-pick]').forEach(b=>{
632    const p=b.dataset.pick;
633    b.classList.toggle('sel',S.predicted===p);
634    b.classList.toggle('correct',!!(S.evaluated&&win===p));
635    b.classList.toggle('wrong',!!(S.evaluated&&S.predicted===p&&win!==p));
636  });
637}
638function updateStrip(){
639  strip.hidden=true; /* top bar removed in settle-race simplify-display */
640  if(S.mode!=='race') return;
641  const sc=settleTime(3), sy=settleTime(S.alpha), sl=settleTime(0.6);
642  const firstPair=Math.min(sc,isFinite(sy)?sy:Infinity);
643  let msg, showCh=false;
644  if(S.t<0.02){
645    /* [pedagogy] F6 — reflection prompt before prediction */
646    const reflectPrefix=discoveredCritical?'Think back: what happened to λ as you increased α past the green tick? ':'';
647    msg=S.predicted
648      ? 'You predicted <b>'+pickPhrase[S.predicted]+'</b>. Press <b>▶ Play</b> to release all three from x₀ = 1 m.'
649      : reflectPrefix+'Same release (x₀ = 1 m, from rest), three dampings. <b>First to rest</b>
649 — inside the ±2 cm band, staying — is…? <span class="dim">(lane 3 follows your α slider)</span>';
650    showCh=!S.predicted;
651  } else if(S.t<firstPair){
652    msg='Racing… watch who holds inside the ±2 cm band first.';
653    showCh=!S.predicted&&S.t<1;
654  } else {
655    let verdict='';
656    if(S.predicted){
657      if(!S.evaluated){ S.evaluated=true; evalBox.style.display='block'; evalBox.innerHTML=longEval(sc,sy); renderChoices(); }
658      verdict='<b>'+shortVerdict(sc,sy)+'</b> ';
659    }
660    msg=verdict+winnerSentence(sc,sy);
661    if(S.t>=sl) msg+=' <span class="dim">Light α = 0.60 rang on until '+sl.toFixed(2)+' s — too little damping is slow, too.</span>';
662    showCh=false;
663  }
664  stripChoices.style.display=showCh?'inline-flex':'none';
665  if(msg!==S._lastMsg){ S._lastMsg=msg; stripMsg.innerHTML=msg; }
666}
667
668/* ---------------- interactions ---------------- */
669function pick(p){
670  S.predicted=p;
671  const sc=settleTime(3), sy=settleTime(S.alpha);
672  const firstPair=Math.min(sc,isFinite(sy)?sy:Infinity);
673  if(S.mode==='race'&&S.t>=firstPair){
674    S.evaluated=true; evalBox.style.display='block'; evalBox.innerHTML=longEval(sc,sy);
675  } else {
676    S.evaluated=false; evalBox.style.display='block';
677    const names={heavy:'the heavy mass (α = 6) wins',critical:'the critical mass (α = ω₀ = 3) wins',tie:'a dead heat'};
678    evalBox.innerHTML='You predicted <b>'+names[p]+'</b>. '+
679      (S.mode==='race'?'Press ▶ Play to release.':'Now open <b>Settle race</b> (header) and press ▶ Play.');
680  }
681  S._lastMsg=null;
682  updateReadouts();
683  renderChoices(); updateStrip(); draw();
684}
685function setMode(m){
686  if(S.mode===m) return;
687  S.mode=m; S.t=0; S.evaluated=false; S._lastMsg=null;
688  if(m==='race'){
689    S.alpha=6; slider.value='6'; Shell.setPlaying(false);
690    enteredRace=true;
691    revealReadouts();
692    revealCritical();
693  }
694  else { Shell.setPlaying(true); }
695  btnSingle.classList.toggle('active',m==='single');
696  btnRace.classList.toggle('active',m==='race');
697  strip.hidden=true;
698  updateReadouts(); renderChoices(); updateStrip(); drawLam();
699  Shell.refit();
700}
701
702slider.addEventListener('input',()=>{
703  S.alpha=parseFloat(slider.value)||0;
704  S.t=0; S._lastMsg=null;
705
706  /* discovery tracking */
707  revealReadouts();
708  if(S.alpha>=W0) revealCritical();
709
710  if(S.evaluated) evalBox.innerHTML=longEval(settleTime(3),settleTime(S.alpha));
711  updateReadouts(); updateStrip(); renderChoices(); drawLam(); draw();
712  Shell.setPlaying(true);
713});
714$('go-critical').addEventListener('click',()=>{ slider.value='3'; slider.dispatchEvent(new Event('input')); });
715btnSingle.addEventListener('click',()=>setMode('single'));
716btnRace.addEventListener('click',()=>setMode('race'));
717document.querySelectorAll('[data-pick]').forEach(b=>b.addEventListener('click',()=>pick(b.dataset.pick)));
718
719/* ---------------- KaTeX (formal panel) ---------------- */
720function renderMath(){
721  if(!window.katex) return;
722  document.querySelectorAll('.eq[data-tex]').forEach(el=>{
723    try{ katex.render(el.dataset.tex, el, {throwOnError:false, displayMode:true}); }catch(e){}
724  });
725}
726if(document.readyState==='complete') renderMath();
727else window.addEventListener('load',renderMath);
728
729/* ---------------- shell callbacks ---------------- */
730function onFrame(dt){
731  S.t+=dt;
732  if(S.mode==='race') updateStrip();
733  draw();
734}
735function onReset(){
736  S.t=0; S.evaluated=false; S._lastMsg=null;
737  if(S.mode!=='race'){ S.mode='single'; S.alpha=0.6; }
738  else S.alpha=6;
739  resetView();
740  slider.value=String(S.alpha);
741  btnSingle.classList.toggle('active', S.mode==='single');
742  btnRace.classList.toggle('active', S.mode==='race');
743  updateReadouts(); renderChoices(); updateStrip(); drawLam(); draw();
744  Shell.setPlaying(true);
745}
746function onResize(){ draw(); drawLam(); }
747/* Completed state: unlock every progressively-revealed readout/control so the
748   sim opens fully live (idempotent — guarded by the discovery flags). */
749function onComplete(){
750  revealReadouts();
751  revealCritical();
752  applyInquiryScene({ mode:'single', alpha:0.6, playing:true, revealReadouts:true, revealCritical:true });
753}
754
755/* ---------- guided-inquiry scene driver (deterministic; chrome only) ---------- */
756function applyInquiryScene(cfg){
757  S.mode = cfg.mode || 'single';
758  S.alpha = cfg.alpha != null ? cfg.alpha : 0.6;
759  S.t = 0;
760  S.evaluated = false;
761  S._lastMsg = null;
762  resetView();
763  if(cfg.clearPredict) S.predicted = null;
764  slider.value = String(S.alpha);
765  btnSingle.classList.toggle('active', S.mode==='single');
766  btnRace.classList.toggle('active', S.mode==='race');
767  strip.hidden = true;
768  if(cfg.revealReadouts || S.mode==='race') revealReadouts();
769  if(cfg.revealCritical || S.mode==='race' || (S.alpha>=W0 && cfg.revealReadouts)) revealCritical();
770  if(S.mode==='race') enteredRace = true;
771  if(S.alpha>=W0) revealCritical();
772  updateReadouts(); renderChoices(); updateStrip(); drawLam(); draw();
773  Shell.refit();
774  if(cfg.playing != null) Shell.setPlaying(!!cfg.playing);
775}
776function onStep(i){
777  // 0 Ground — underdamped ringing
778  if(i===0) applyInquiryScene({ mode:'single', alpha:0.6, playing:true, revealReadouts:true });
779  // 1 Critical — no overshoot, λ peak
780  else if(i===1) applyInquiryScene({ mode:'single', alpha:W0, playing:true, revealReadouts:true, revealCritical:true });
781  // 2 Overdamped — λ has fallen
782  else if(i===2) applyInquiryScene({ mode:'single', alpha:6, playing:true, revealReadouts:true, revealCritical:true });
783  // 3 Predict race — paused on race strip
784  else if(i===3) applyInquiryScene({ mode:'race', alpha:6, playing:false, clearPredict:true, revealReadouts:true, revealCritical:true });
785  // 4 Run the race
786  else if(i===4) applyInquiryScene({ mode:'race', alpha:6, playing:true, revealReadouts:true, revealCritical:true });
787  // 5 Resolve / lecture default — one mass underdamped, playing
788  else if(i===5) applyInquiryScene({ mode:'single', alpha:0.6, playing:true, revealReadouts:true, revealCritical:true });
789}
790
791/* ---------- guided-inquiry prediction feedback ---------- */
792document.querySelectorAll('#inq-cards .inq-step').forEach(card=>{
793  const evalBox=card.querySelector('.predict-eval');
794  const choices=card.querySelectorAll('.choice');
795  choices.forEach(ch=>ch.addEventListener('click',()=>{
796    if(card.dataset.answered) return; card.dataset.answered='1';
797    const correct=ch.dataset.correct==='1';
798    choices.forEach(c=>{ c.disabled=true;
799      if(c.dataset.correct==='1') c.classList.add('correct');
800      else if(c===ch) c.classList.add('wrong'); else c.classList.add('dim'); });
801    if(evalBox){ evalBox.innerHTML=ch.dataset.fb||''; evalBox.classList.add('show', correct?'correct':'wrong'); }
802  }));
803});
804
805/* ---------------- machine-readable audit hook (classical — no Lorentz γ) ---------------- */
806window.__audit={ at(alpha){ return { decayRate: slowRate(alpha) }; } };
807
808Shell.init({ onFrame, onReset, onResize, onStep, onComplete });
809// Shell.init runs onReset after inqShow(0); re-apply the opening card's scene.
810if(Shell.totalSteps > 0) onStep(Shell.step);
811})();
812;
813/* Open in Lecture mode by default — trigger the shell's lecture toggle once on load. */
814requestAnimationFrame(function(){var b=document.getElementById('shell-lecture'),r=document.getElementById('shell');
814if(b&&r&&!r.classList.contains('lecture-mode'))b.click();});
815
816/* ---- the beat driver ---- */
817/* Beat 3, the simulator. Set B: "The Damped Oscillator, Under, Over, and Critical".
818
819   One global, BeatSim, with init(root) and setProgress(p), p in 0..1 across the beat. No
820   listeners on window or document: Builder A calls setProgress from the page's single rAF loop.
821   The only observer is a ResizeObserver on the canvas box, which is what tells the sim to refit
822   when mobile chrome resizes the visual viewport under it.
823
824   Timing, all as a fraction of the beat, carried over verbatim from the approved desktop beat:
825
826     0.10 - 0.30   the headline card travels from the middle of the screen to its slot above the
827                   panel. Translate only, so the type never rasterises at the wrong size
828     0.14 - 0.32   the panel unfolds: scaleY from a hairline to 1 while scaleX comes in from 0.6,
829                   which reads as the simulator opening out of the middle of the screen
830     0.34 - 0.62   the damping sweeps, from 0 to critical
831     0.62 - 1      a hold on critical damping, 38% of the beat
832
833   What the sweep does here. Client, 10 Sep: "In the sim, slider need not go all the way to the
834   left when swiping. it can remain upto halfway, then halt for the user to see the sim and then
835   scroll away." Halfway is the physically interesting place to stop, which is why this is a
836   better beat and not just a shorter one. The sim's own natural frequency is omega-zero = 3
837   rad/s and the control runs 0 to 6, so the midpoint of the track IS critical damping: the
838   sweep takes the mass from ringing freely to the fastest return that never overshoots, stops
839   on that boundary, and the rail reads Critical while the visitor looks at it.
840
841   The control's range does not change. `max` stays 6, so the thumb stopping at A_HOLD stops
842   visually halfway along the track, which is what was asked for. What changed is the top of the
843   sweep, A_HOLD, and where the sweep ends, SWEEP_B.
844
845   Landing exactly on 3.00 is deliberate, not a float accident. setAlpha rounds to the slider's
846   own 0.05 step, and at p >= SWEEP_B the ramp is exactly 1, so the value is exactly 3.00. The
847   regime classifier below has a plus or minus 0.03 band around omega-zero, and 3.00 is its
848   centre; the nearest reachable neighbours, 2.95 and 3.05, both fall outside it. So the readout
849   reads Critical on the hold and can only read Critical there.
850
851   Scroll owns the damping the whole way. A visitor can drag the slider and see the sim answer,
852   and the next scroll takes the value back, which is what was asked for. There is no "user has
853   taken over" latch.
854
855   Nothing redraws the canvas while the panel is mid unfold, for the reason recorded on
856   pinCanvasBox below. */
857var BeatSim = (function(){
858  'use strict';
859
860  var OPEN_A = 0.14, OPEN_B = 0.32;      // the panel unfolds
861  var SWEEP_A = 0.34, SWEEP_B = 0.62;    // the damping sweep, then a hold to the end of the beat
862  var CARD_A = 0.10, CARD_B = 0.30;      // the headline card travels to its resting slot
863  var A_MIN = 0, A_MAX = 6, W0 = 3;      // the control's range, and the sim's own omega-zero
864  var A_HOLD = W0;                       // where the sweep stops: halfway, which is critical
865
866  var root, copy, panel, host;
867  var iAlpha, vAlpha, oDecay, oRegime;
868  var lastA = null, lastU = -1, lastC = -1, cardDy = 0;
869  var opened = false, flown = false, booted = false, driven = false;
870  var pendingA = null, refitQueued = false, bootTries = 0, armed = false;
871
872  function $(id){ return document.getElementById(id); }
873  function clamp01(v){ return v < 0 ? 0 : (v > 1 ? 1 : v); }
874  function ramp(p, a, b){ return clamp01((p - a) / (b - a)); }
875  function ease(u){ return u < .5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; }
876  function held(){ return driven && !opened; }          // panel mid unfold: hold the last render
877
878  /* the sim listens for plain bubbling input events on its own range input */
879  function simInput(id, v){
880    var el = $(id); if(!el) return;
881    el.value = String(v);
882    el.dispatchEvent(new Event('input', {bubbles:true}));
883  }
884
885  /* The rail's two readouts. Decay is the sim's own lambda-readout, which is the slow root of the
886     characteristic equation and is what "how fast does it die" actually means; it is read back
887     rather than recomputed so the panel can never disagree with the picture. Regime is the one
888     derived value: alpha against the sim's omega-zero of 3, which is the sentence the sweep is
889     drawing and is not written anywhere the beat can see. */
890  function readout(a){
891    var l = $('lambda-readout'), t;
892    if(oDecay && l){
893      t = (l.textContent || '').replace(/\s+/g, ' ').replace(/^\s*λ\s*=\s*/, '').trim();
894      oDecay.textContent = t || '–';
895    }
896    if(oRegime){
897      oRegime.textContent = a < W0 - 0.03 ? 'Under' : (a > W0 + 0.03 ? 'Over' : 'Critical');
898    }
899  }
900  function setAlpha(v, force){
901    v = Math.round(Number(v) * 20) / 20;                 /* the slider's own 0.05 step */
902    if(v === lastA && !force) return;
903    lastA = v;
904    if(iAlpha) iAlpha.value = String(v);
905    if(vAlpha) vAlpha.textContent = v.toFixed(2);
906    if(held() && !force){ pendingA = v; return; }
907    pendingA = null;
908    simInput('alpha', v);
909    readout(v);
910  }
911  function playing(on){
912    try{ if(typeof Shell !== 'undefined' && Shell.setPlaying) Shell.setPlaying(!!on); }catch(e){}
913  }
914
915  /* The sim sizes its canvas from getBoundingClientRect, which reports the box after the unfold
916     transform: at a hairline it clears the backing store and then bails on a degenerate size, and
917     the picture is gone. Its own boot scripts refit a frame or two after mount, so this cannot be
918     dodged by ordering alone. Report the untransformed layout box instead, and the sim always
919     renders at full size while the compositor squashes it, which is the effect wanted. */
920  function pinCanvasBox(){
921    var cv = host && host.querySelector('#bs-stage');
922    if(!cv || cv.__bsPinned) return;
923    cv.__bsPinned = true;
924    var real = cv.getBoundingClientRect.bind(cv);
925    cv.getBoundingClientRect = function(){
926      var r = real();
927      if(!held()) return r;
928      var w = cv.offsetWidth, h = cv.offsetHeight;
929      return {x:r.x, y:r.y, top:r.top, left:r.left, right:r.left + w, bottom:r.top + h,
930              width:w, height:h, toJSON:function(){ return this; }};
931    };
932  }
933
934  /* How far down the card sits in move 1: the middle of the screen under the nav, less where the
935     card rests in move 2. One forced layout, on init and on a viewport change, never per frame. */
936  function measure(){
937    if(!root || !copy) return;
938    var prev = copy.style.transform;
939    copy.style.transform = 'none';
940    var rr = root.getBoundingClientRect(), cr = copy.getBoundingClientRect();
941    var nav = parseFloat(getComputedStyle(root).getPropertyValue('--bs-nav')) || 60;
942    cardDy = (rr.top + nav + (rr.height - nav) * 0.46) - (cr.top + cr.height / 2);
943    if(!(cardDy > 0)) cardDy = 0;
944    copy.style.transform = prev;
945    lastC = -1;
946  }
947
948  /* One refit per frame at most, and never while the panel is mid unfold. */
949  function refit(){
950    if(refitQueued) return;
951    refitQueued = true;
952    requestAnimationFrame(function(){
953      refitQueued = false;
954      measure();
955      if(held()) return;
956      try{ window.dispatchEvent(new Event('resize')); }catch(e){}
957    });
958  }
959
960  /* The sim's own script runs above this one, so its shell is already mounted. Boot means: stop
961     the opening shot until the beat is reached, then move the slider once, which is what puts the
962     trace on screen. Booting at A_MIN, the value the sweep starts from, means the first render is
963     already the one the sweep continues. */
964  function boot(){
965    if(booted) return;
966    var cv = host && host.querySelector('#bs-stage');
967    var r = cv ? cv.getBoundingClientRect() : null;
968    if(!r || r.width < 40 || r.height < 40){
969      if(bootTries++ < 120) requestAnimationFrame(boot);
970      return;
971    }
972    booted = true;
973    playing(false);
974    setAlpha(A_MIN, true);
975  }
976
977  /* THE SIM APPLIES ITS OWN OPENING SCENE AFTER BOOT, AND IT DOES SO ON ITS OWN CLOCK.
978     Shell.init runs onReset, and onStep runs applyInquiryScene a few frames later; both write
979     the sim's own alpha (0.6) and both set it playing. boot() already asked for A_MIN and a
980     pause, but it can lose that race - measured at 6ms in the ported page, where the fragment's
981     script is a file that loads a task later than the inline copy the prototype carries. When it
982     loses, the panel unfolds onto a full 0.6-damped waveform that the first sweep step then
983     wipes, which is exactly what the client saw on a phone: "the sim seems to have one starting
984     frame where the full waveform is drawn, before it goes away".
985
986     So the beat re-states what it wants at the one moment that matters, when the panel finishes
987     opening and the picture becomes visible. Ordering is no longer load-bearing: whatever the sim
988     did while it was a hairline is overwritten before anyone sees it. The reset is what clears a
989     trace drawn during that invisible run, so the first visible frame is an empty chart with the
990     mass at x0, and the sweep draws from there. */
991  function reassert(){
992    var want = pendingA !== null ? pendingA : (lastA === null ? A_MIN : lastA);
993    var rb = host && host.querySelector('#shell-reset');
994    if(rb){ try{ rb.click(); }catch(e){} }     /* trace back to t=0; onReset re-applies the sim's own scene */
995    setAlpha(want, true);                      /* and the beat's value wins over it */
996    if(!flown) playing(false);                 /* setProgress releases it on the same call */
997  }
998
999  function drive(p){
1000    driven = true;
1001    /* The first frame of the beat, before the unfold has started. This is the one that matters:
1002       the panel spends 0.14 to 0.32 of the beat opening, it is already visible for most of that,
1003       and NOTHING redraws the canvas while it is mid unfold (see pinCanvasBox), so whatever was
1004       last drawn is what the visitor watches unfold. Asserting only at full open left the sim's
1005       own 0.6-damped waveform on screen for the whole opening and replaced it a frame later. */
1006    if(!armed && p > 0){ armed = true; reassert(); }
1007    var u = ease(ramp(p, OPEN_A, OPEN_B));
1008    if(u !== lastU){
1009      lastU = u;
1010      panel.style.transform = 'scale(' + (0.6 + 0.4 * u).toFixed(4) + ',' + (0.02 + 0.98 * u).toFixed(4) + ')';
1011      panel.style.opacity = (u * 3 > 1 ? 1 : u * 3).toFixed(3);
1012      var was = opened;
1013      opened = u >= 0.999;
1014      if(opened && !was){
1015        reassert();
1016        refit();
1017      }
1018    }
1019    var c = ease(ramp(p, CARD_A, CARD_B));
1020    if(c !== lastC){
1021      lastC = c;
1022      copy.style.transform = 'translate3d(0,' + (cardDy * (1 - c)).toFixed(2) + 'px,0)';
1023    }
1024    /* Scroll owns this the whole way, including after a drag, and it now takes the value back
1025       to A_HOLD rather than to the top of the control's range. */
1026    setAlpha(A_MIN + ramp(p, SWEEP_A, SWEEP_B) * (A_HOLD - A_MIN));
1027  }
1028
1029  return {
1030    /* Builder A calls this once, after the fragment is in the document. */
1031    init: function(el){
1032      root = el || document.getElementById('beatSim');
1033      if(!root || root.getAttribute('data-bs-init')) return;
1034      root.setAttribute('data-bs-init', '1');
1035      copy = $('bsCopy'); panel = $('bsPanel'); host = $('bsSimHost');
1036      iAlpha = $('bsAlpha'); vAlpha = $('bsAlphaV');
1037      oDecay = $('bsDecay'); oRegime = $('bsRegime');
1038      if(!copy || !panel || !host) return;
1039
1040      if(iAlpha) iAlpha.addEventListener('input', function(){ setAlpha(iAlpha.value, true); });
1041      if(window.ResizeObserver){ try{ new ResizeObserver(refit).observe(host); }catch(e){} }
1042      pinCanvasBox();
1043
1044      boot();
1045      measure();
1046      /* nothing has driven the beat yet: card in the middle of the screen, panel shut */
1047      drive(0);
1048    },
1049
1050    /* p is 0..1 across the beat. Called from the page's one rAF loop. */
1051    setProgress: function(p){
1052      if(!root) return;
1053      p = clamp01(Number(p) || 0);
1054      if(!booted) boot();
1055      drive(p);
1056      /* the sim's own release, once the panel is open and the visitor is here to see it */
1057      if(!flown && opened && p > OPEN_B){ flown = true; playing(true); }
1058    },
1059
1060    /* Optional. Builder A can call setActive(false) when another beat owns the stage: it hides
1061       the beat and pauses the sim's rAF loop, so an off screen canvas costs nothing. */
1062    setActive: function(on){
1063      if(!root) return;
1064      root.classList.toggle('is-off', !on);
1065      if(!on) playing(false);
1066      else refit();
1067    }
1068  };
1069})();
1070window.BeatSim = BeatSim;
1071
1072})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.