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 α > Ïâ 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 α > Ïâ 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 ' < Ïâ) 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.