1/* Hide Text toggle â empty registry; hides .ht-hide DOM nodes and gates HT() canvas labels. */ 2(function(){ 3 var shell=document.getElementById('shell'); 4 var box=document.getElementById('ht-toggle'); 5 if(!shell||!box) return; 6 window.HT=function(){ return shell.classList.contains('hide-text'); }; 7 function apply(){ 8 shell.classList.toggle('hide-text', box.checked); 9 window.dispatchEvent(new Event('resize')); /* forces a redraw in every CM sim, even while paused */ 10 } 11 box.addEventListener('change', apply); 12 /* Default: follow lecture mode at boot. The auto-lecture script clicks ð on the 13 first rAF, so read the state one frame later. Independent thereafter. */ 14 requestAnimationFrame(function(){ requestAnimationFrame(function(){ 15 box.checked = shell.classList.contains('lecture-mode'); 16 apply(); 17 });}); 18})(); 19; 20/* ============================================================================ 21 SHELL RUNTIME â emit VERBATIM. The SIM provides onFrame/onReset/onResize. 22 ========================================================================== */ 23const Shell = (function(){ 24 const root = document.getElementById('shell'); 25 let onFrame=()=>{}, onReset=()=>{}, onResize=()=>{}, onStep=()=>{}, onComplete=()=>{}; 26 let playing=true, speed=1, last=performance.now(), started=false; 27 let inqStep=0, inqTotal=0; // guided-inquiry stepper (shell-owned) 28 29 function loop(now){ 30 let dt=(now-last)/1000; if(dt>0.1)dt=0.1; last=now; 31 if(playing) onFrame(dt*speed); 32 requestAnimationFrame(loop); 33 } 34 function refit(){ onResize(); last=performance.now(); } 35 36 function setPlaying(p){ 37 playing=p; last=performance.now(); 38 const b=document.getElementById('shell-play'); 39 if(b){ b.textContent = p ? '⸠Pause' : 'â¶ Play'; b.classList.toggle('paused', !p); } 40 } 41 function setMaximized(on){ 42 root.classList.toggle('shell-max', on); 43 const maxBtn=document.getElementById('shell-maximize'); 44 if(maxBtn){ 45 maxBtn.classList.toggle('active', on); 46 maxBtn.textContent = on ? '⤡ Minimize' : 'â¶ Maximize'; 47 } 48 requestAnimationFrame(refit); 49 } 50 51 // Dynamic sidebar: collapse the guided-inquiry zone to its restore bar (on 52 // answer or Skip) or expand it again (restore-bar click). Refit canvases so 53 // the sidebar height change never distorts the hero. 54 function setInquiryCollapsed(on){ 55 root.classList.toggle('inquiry-collapsed', on); 56 requestAnimationFrame(refit); 57 } 58 function setLectureMode(on){ 59 root.classList.toggle('lecture-mode', on); 60 const b = document.getElementById('shell-lecture'); 61 if(b) b.classList.toggle('active', on); 62 if(on){ 63 const cards = inqCards(); 64 if(cards.length) inqShow(cards.length - 1); // jump to last step â onStep(last) paints the complete scene 65 setInquiryCollapsed(true); // hide inquiry â restore bar 66 /* Lecture default: clean scene â axes/grid off; hide range plot control */ 67 const gt = document.getElementById('gridToggle'); 68 if(gt && gt.checked){ gt.checked = false; gt.dispatchEvent(new Event('change')); } 69 const bc = document.getElementById('btn-curve'); 70 if(bc && bc.classList.contains('active')) bc.click(); /* close plot if open */ 71 } else { 72 setInquiryCollapsed(false); 73 inqShow(0); // re-initiate guided inquiry from step 1 74 } 75 } 76 77 // ---- Guided-inquiry stepper (shell-owned, so Next/Finish are always wired) ---- 78 // The SIM supplies sequential <div class="inq-step"> cards in #inq-cards. The 79 // shell shows ONE at a time, builds the dots, and drives the Next/Finish button. 80 // A card marked data-gate keeps Next disabled until the student clicks a .choice 81 // inside it (auto-detected) or the SIM calls Shell.stepReady(). 82 function inqCards(){ return root.querySelectorAll('#inq-cards .inq-step'); } 83 function inqUpdatePager(){ 84 const cards=inqCards(); 85 const prev=document.getElementById('inq-prev'); 86 const pnext=document.getElementById('inq-pager-next'); 87 if(prev) prev.disabled = inqStep<=0; 88 if(pnext) pnext.disabled = inqStep>=cards.length-1; 89 } 90 function inqPrev(){ 91 if(inqStep>
910) inqShow(inqStep-1); 92 } 93 function inqPagerNext(){ 94 const cards=inqCards(); 95 if(inqStep<cards.length-1) inqShow(inqStep+1); 96 } 97 function inqShow(n){ 98 const cards=inqCards(); 99 if(!cards.length) return; 100 inqStep=Math.max(0,Math.min(n,cards.length-1)); 101 cards.forEach((c,i)=>c.classList.toggle('active',i===inqStep)); 102 root.querySelectorAll('#inq-dots .inq-dot').forEach((d,i)=>{ 103 d.classList.toggle('active',i===inqStep); 104 d.classList.toggle('done',i<inqStep); 105 }); 106 const next=document.getElementById('inq-next'); 107 if(next){ 108 const card=cards[inqStep]; 109 const gated = card && card.hasAttribute('data-gate') && !card.hasAttribute('data-ready'); 110 next.disabled = gated; 111 next.textContent = (inqStep>=cards.length-1) ? 'Finish' : 'Next â'; 112 } 113 inqUpdatePager(); 114 onStep(inqStep); 115 requestAnimationFrame(refit); 116 } 117 function inqNext(){ 118 const cards=inqCards(); 119 if(inqStep>=cards.length-1) finishInquiry(); // Finish = jump to completed state + hide the inquiry 120 else inqShow(inqStep+1); 121 } 122 // Finish OR Skip: fast-forward through any remaining steps (applying each 123 // onStep so the scene ends in the SAME fully-revealed state as completing the 124 // inquiry), let the SIM set up free exploration via onComplete(), collapse the 125 // zone, and leave the sim live so the student can play with every control. 126 function finishInquiry(){ 127 const cards=inqCards(); 128 if(cards.length){ 129 for(let k=inqStep+1;k<cards.length;k++) onStep(k); 130 inqStep=cards.length-1; 131 root.querySelectorAll('#inq-dots .inq-dot').forEach((d,i)=>{ 132 d.classList.toggle('active',i===inqStep); 133 d.classList.toggle('done',i<inqStep); 134 }); 135 } 136 onComplete(); 137 setInquiryCollapsed(true); 138 setPlaying(true); 139 } 140 // SIM may call this to satisfy a data-gate step's requirement (e.g. "watched"). 141 function stepReady(){ 142 const card=inqCards()[inqStep]; 143 if(card) card.setAttribute('data-ready',''); 144 const next=document.getElementById('inq-next'); if(next) next.disabled=false; 145 } 146 147 function wire(){ 148 const play=document.getElementById('shell-play'); 149 const reset=document.getElementById('shell-reset'); 150 const spd=document.getElementById('shell-speed'); 151 const maxBtn=document.getElementById('shell-maximize'); 152 const restore=document.getElementById('aside-inquiry-restore'); 153 const tFormal=document.getElementById('toggle-formal'); 154 155 if(play) play.addEventListener('click',()=>setPlaying(!playing)); 156 // Reset zeroes state AND ensures the loop is running (replay-from-start). 157 if(reset) reset.addEventListener('click',()=>{ onReset(); last=performance.now(); setPlaying(true); }); 158 if(spd) spd.addEventListener('change',e=>{ speed=parseFloat(e.target.value)||1; }); 159 160 if(maxBtn) maxBtn.addEventListener('click',()=>setMaximized(!root.classList.contains('shell-max'))); 161 162 const infoBtn=document.getElementById('shell-info'); 163 const infoModal=document.getElementById('shell-info-modal'); 164 const infoClose=document.getElementById('shell-info-close'); 165 function setInfo(open){ if(infoModal) infoModal.classList.toggle('open',open); } 166 if(infoBtn) infoBtn.addEventListener('click',()=>setInfo(true)); 167 if(infoClose) infoClose.addEventListener('click',()=>setInfo(false)); 168 if(infoModal) infoModal.addEventListener('click',e=>{ if(e.target===infoModal) setInfo(false); }); 169 170 // Light/dark theme toggle (default dark). Swaps the icon and refits canvases. 171 const themeBtn=document.getElementById('shell-theme'); 172 if(themeBtn) themeBtn.addEventListener('click',()=>{ 173 const light=document.body.classList.toggle('light-theme'); 174 themeBtn.textContent = light ? 'â' : 'â¾'; 175 requestAnimationFrame(refit); 176 }); 177 const lecture = document.getElementById('shell-lecture'); 178 if(lecture) lecture.addEventListener('click', ()=> setLectureMode(!root.classList.contains('lecture-mode'))); 179 180 document.addEventListener('keydown',e=>{ 181 if(e.key!=='Escape') return; 182 if(infoModal && infoModal.classList.contains('open')) setInfo(false); 183 else if(root.classList.contains('shell-max')) setMaximized(false); 184 }); 185 186 // Restore bar exits lecture mode and re-initiates inquiry from step 1. 187 if(restore) restore.addEventListener('click',()=> setLectureMode(false)); 188 // Next / Finish â shell-driven step advance (Finish collapses the inquiry). 189 const inqNextBtn=document.getElementById('inq-next'); 190 if(inqNextBtn) inqNextBtn.addEventListener('click',inqNext); 191 const inqPrevBtn=document.getElementById('inq-prev'); 192 if(inqPrevBtn) inqPrevBtn.addEventListener('click',inqPrev); 193 const inqPagerNextBtn=document.getElementById('inq-pager-next'); 194 if(inqPagerNextBtn) inqPagerNextBtn.addEventListener('click',inqPagerNext); 195 // Auto-gate: a .choice click inside the active gated card enables Next. 196 const cardsHost=document.getElementById('inq-cards'); 197 if(cardsHost) cardsHost.addEventListener('click',e=>{ 198 const ch=e.target.closest('.choice'); if(!ch) return; 199 const card=ch.closest('.inq-step'); 200 if(card && card.classList.contains('active')) stepReady(); 201 }); 202 if(tFormal) tFormal.addEventListener('click',()=>{ 203 const hidden=root.classList.toggle('hide-formal'); 204 tFormal.classList.toggle('active',!hidden); 205 requestAnimationFrame(refit); 206 }); 207 208 root.querySelectorAll('.shell-panel-head').forEach(head=>{ 209 head.addEventListener('click',()=>{ 210 head.parentElement.classList.toggle('collapsed'); 211 requestAnimationFrame(refit); 212 }); 213 }); 214 215 let rt; window.addEventListener('resize',()=>{ clearTimeout(rt); rt=setTimeout(refit,80); }); 216 } 217 218 return { 219 init(opts){ 220 onFrame=opts.onFrame||onFrame; onReset=opts.onReset||onReset; onResize=opts.onResize||onResize; onStep=opts.onStep||onStep; onComplete=opts.onComplete||onComplete; 221 wire(); 222 // Build the guided-inquiry stepper from the .inq-step cards the SIM supplied.
223 const cards=inqCards(); 224 inqTotal=cards.length; 225 if(inqTotal>0){ 226 const dots=document.getElementById('inq-dots'); 227 if(dots){ dots.innerHTML=''; for(let i=0;i<inqTotal;i++){ const d=document.createElement('span'); d.className='inq-dot'; dots.appendChild(d); } } 228 inqShow(0); 229 } else { 230 root.classList.add('no-inquiry'); // no inquiry â hide the whole zone + restore bar 231 } 232 onResize(); onReset(); 233 last=performance.now(); 234 if(!started){ started=true; requestAnimationFrame(loop); } 235 }, 236 get speed(){return speed;}, get playing(){return playing;}, 237 // Guided-inquiry step (current 0-based index). The SIM reads it / its onStep 238 // callback to evolve the scene as the student advances. 239 get step(){return inqStep;}, get totalSteps(){return inqTotal;}, 240 // SIM calls this to satisfy a data-gate step (e.g. after the student has watched). 241 stepReady, 242 setPlaying, refit 243 }; 244})(); 245 246(function(){ 247'use strict'; 248 249const g = 9.81, RAD = Math.PI/180; 250const DEF = { v0:29, thetaDeg:45, b:0.30, c:0.007, m:1.0, model:'lin', showAxes:false }; 251let P = { ...DEF }; 252const COL = { vac:'#22d3ee', drag:'#f59e0b', quad:'#a78bfa', star:'#fde047' }; 253/* Fixed scene window â vacuum envelope at new slider maxima (v0=30 â R=91.7 m). */ 254const WORLD_X = 95; // m â matches R(θ) plot R-axis 255const WORLD_Y = 38; // m â H_max at 60°, v0=30 â 34.4 m + headroom 256const PLOT_RMAX = 95; // m â fixed R(θ) vertical axis (v0=30 â R_vac,max = 91.7 m) 257 258/* ---------------- exact physics ---------------- */ 259function vacuumFlight(v0, th){ 260 const vx = v0*Math.cos(th), vy = v0*Math.sin(th); 261 return { 262 T: 2*vy/g, 263 R: v0*v0*Math.sin(2*th)/g, 264 x: t => vx*t, 265 y: t => vy*t - 0.5*g*t*t, 266 vx: () => vx, 267 vy: t => vy - g*t, 268 tApex: vy/g 269 }; 270} 271function dragFlight(v0, th, b, m){ 272 if(b < 1e-9) return vacuumFlight(v0, th); // exact bâ0 limit 273 const vx0 = v0*Math.cos(th), vy0 = v0*Math.sin(th); 274 const tau = m/b, vT = m*g/b; 275 const X = t => vx0*tau*(1 - Math.exp(-t/tau)); 276 const Yf = t => (vy0 + vT)*tau*(1 - Math.exp(-t/tau)) - vT*t; 277 // flight time: bisection on the exact y(t) (no closed form) 278 let hi = 2*vy0/g, n = 0; 279 while(Yf(hi) > 0 && n++ < 80) hi *= 1.5; 280 let lo = hi*1e-9; 281 for(let i=0;i<80;i++){ const mid = 0.5*(lo+hi); if(Yf(mid) > 0) lo = mid; else hi = mid; } 282 const T = 0.5*(lo+hi); 283 return { 284 T, R: X(T), x: X, y: Yf, 285 vx: t => vx0*Math.exp(-t/tau), 286 vy: t => (vy0 + vT)*Math.exp(-t/tau) - vT, 287 tApex: tau*Math.log(1 + vy0/vT), 288 vT, tau 289 }; 290} 291 292/* Quadratic drag a = âg ĵ â (c/m)|v|v â no closed form; RK4 table + interpolators. */ 293function quadFlight(v0, th, c, m, dt){ 294 if(dt == null) dt = 0.002; 295 if(c < 1e-9) return vacuumFlight(v0, th); // exact câ0 limit 296 const k = c/m; 297 const ts=[], xs=[], ys=[], vxs=[], vys=[]; 298 let s=[0, 0, v0*Math.cos(th), v0*Math.sin(th)], t=0; 299 const push=()=>{ ts.push(t); xs.push(s[0]); ys.push(s[1]); vxs.push(s[2]); vys.push(s[3]); }; 300 push(); 301 function deriv(st){ 302 const vx=st[2], vy=st[3], sp=Math.hypot(vx,vy); 303 return [vx, vy, -k*sp*vx, -g - k*sp*vy]; 304 } 305 function addScaled(st, kk, h){ 306 return [st[0]+h*kk[0], st[1]+h*kk[1], st[2]+h*kk[2], st[3]+h*kk[3]]; 307 } 308 function rk4(st, h){ 309 const k1=deriv(st), k2=deriv(addScaled(st,k1,h/2)), k3=deriv(addScaled(st,k2,h/2)), k4=deriv(addScaled(st,k3,h)); 310 return [ 311 st[0]+h/6*(k1[0]+2*k2[0]+2*k3[0]+k4[0]), 312 st[1]+h/6*(k1[1]+2*k2[1]+2*k3[1]+k4[1]), 313 st[2]+h/6*(k1[2]+2*k2[2]+2*k3[2]+k4[2]), 314 st[3]+h/6*(k1[3]+2*k2[3]+2*k3[3]+k4[3]) 315 ]; 316 } 317 while(t < 60){ 318 s = rk4(s, dt); t += dt; push(); 319 if(ys[ys.length-1] < 0 && ys.length > 1) break; 320 } 321 const i = ys.length - 1; 322 const y0 = ys[i-1], y1 = ys[i]; 323 const frac = (y0 - y1) !== 0 ? y0/(y0 - y1) : 1; 324 const T = ts[i-1] + frac*(ts[i] - ts[i-1]); 325 const R = xs[i-1] + frac*(xs[i] - xs[i-1]); 326 let tApex = 0; 327 for(let j=0; j<vys.length-1; j++){ 328 if(vys[j] >= 0 && vys[j+1] < 0){ 329 const f = vys[j]/(vys[j] - vys[j+1]); 330 tApex = ts[j] + f*(ts[j+1] - ts[j]); 331 break; 332 } 333 } 334 function lerp(arr, tt){ 335 if(tt <= 0) return arr[0]; 336 if(tt >= T){ 337 /* clamp to landing: interpolate final bracket at T */ 338 const f = (T - ts[i-1])/(ts[i] - ts[i-1]); 339 return arr[i-1] + f*(arr[i] - arr[i-1]); 340 } 341 let lo=0, hi=ts.length-1; 342 while(hi - lo > 1){ 343 const mid=(lo+hi)>>1; 344 if(ts[mid] <= tt) lo=mid; else hi=mid; 345 } 346 const u = (tt - ts[lo])/(ts[hi] - ts[lo]); 347 return arr[lo] + u*(arr[hi] - arr[lo]); 348 } 349 return { 350 T, R, 351 x: tt => lerp(xs, Math.max(0, Math.min(T, tt))), 352 y: tt => lerp(ys, Math.max(0, Math.min(T, tt))), 353 vx: tt => lerp(vxs, Math.max(0, Math.min(T, tt))), 354 vy: tt => lerp(vys, Math.max(0, Math.min(T, tt))), 355 tApex, 356 vT: Math.sqrt(m*g/c) 357 }; 358} 359 360/* ---------------- DOM ---------------- */ 361const $ = id => document.getElementById(id); 362const fieldC = $('field'), fctx = fieldC.getContext('2d'); 363const curveC = $('curve'), cctx = curveC.getContext('2d'); 364const heroWrap = $('hero-wrap'); 365const hudRange = $('hud-range'), hudBest = $('hud-best'), hudLegend = $('hud-legend'); 366const slTh = $('sl-theta'), slV = $('sl-v0'), slB = $('sl-b'), slC = $('sl-c'), slM = $('sl-m'); 367const vTh = $('val-theta'), vV = $('val-v0'), vB = $('val-b'), vC = $('val-c'), vM = $('val-m');
368const mVac = $('mode-vac'), mDrag = $('mode-drag'), btnCurve = $('btn-curve'); 369const kindLin = $('kind-lin'), kindQuad = $('kind-quad'); 370const dragKindRow = $('drag-kind-row'); 371const btnLaunch = $('btn-launch'); 372const boxB = $('box-b'), boxC = $('box-c'); 373const valT = $('val-T'), valVT = $('val-vT'), valTau = $('val-tau'), hudTau = $('hud-tau'); 374 375/* ---------------- state ---------------- */ 376let vacF=null, linF=null, quaF=null, actF=null; 377let pathVac=[], pathLin=[], pathQua=[]; 378let curveVac=[], curveLin=[], curveQuad=[]; 379let opt = { deg:45, R:0 }; 380let best = { R:-1, deg:45 }; 381let curveOpen = false; 382let tFly=0, puffAge=9; 383let lastDrag = 'lin'; /* remember Linear vs Quadratic when toggling Vacuum â With drag */ 384 385/* [pedagogy] activation state */ 386let activated = false; // trajectories hidden until first interaction 387 388/* ---------------- helpers ---------------- */ 389function fit(canvas, ctx){ 390 const dpr = window.devicePixelRatio||1; 391 const r = canvas.getBoundingClientRect(); 392 const w = Math.floor(r.width*dpr), h = Math.floor(r.height*dpr); 393 if(canvas.width!==w||canvas.height!==h){ canvas.width=w; canvas.height=h; } 394 ctx.setTransform(1,0,0,1,0,0); ctx.scale(dpr,dpr); 395 return { w:r.width, h:r.height }; 396} 397function samplePath(f){ 398 const N=140, out=new Array(N+1); 399 for(let i=0;i<=N;i++){ const t=f.T*i/N; out[i]=[f.x(t), Math.max(0,f.y(t))]; } 400 return out; 401} 402function cycleT(){ return Math.max(vacF.T, linF.T, quaF.T); } 403function flightComplete(){ return !!(vacF && linF && quaF && tFly >= cycleT() - 1e-9); } 404function activeFlight(){ return P.model==='vac' ? vacF : (P.model==='quad' ? quaF : linF); } 405function activeColor(){ return P.model==='vac' ? COL.vac : (P.model==='quad' ? COL.quad : COL.drag); } 406function modelLabel(){ return P.model==='vac' ? 'vacuum' : (P.model==='quad' ? 'quad drag' : 'linear drag'); } 407function modelKind(){ return P.model; } /* 'vac' | 'lin' | 'quad' â matches plot curve kinds */ 408function pickStep(pxPerM){ 409 const c=[0.5,1,2,5,10,20,25,50,100,200]; 410 for(const v of c) if(v*pxPerM>=52) return v; 411 return 500; 412} 413function pickAxisStep(maxV, ihPx){ 414 const c=[0.5,1,2,5,10,20,25,50,100,200]; 415 for(const v of c) if(v/maxV*ihPx>=26) return v; 416 return 500; 417} 418const fmtTick = v => v<1 ? v.toFixed(1) : String(Math.round(v)); 419function drawLabel(ctx,text,x,y,color,align,W){ 420 const tw = ctx.measureText(text).width; 421 let lx = align==='center' ? x-tw/2 : (align==='right' ? x-tw : x); 422 lx = Math.max(4, Math.min(lx, W-tw-4)); 423 const ly = Math.max(13, y); 424 ctx.save(); ctx.textAlign='left'; ctx.textBaseline='alphabetic'; 425 ctx.lineWidth=3; ctx.strokeStyle='rgba(8,10,14,.85)'; ctx.strokeText(text,lx,ly); 426 ctx.fillStyle=color; ctx.fillText(text,lx,ly); ctx.restore(); 427} 428function drawArrow(ctx,x,y,ux,uy,len,col){ 429 const hx=x+ux*len, hy=y+uy*len; 430 ctx.save(); ctx.strokeStyle=col; ctx.fillStyle=col; ctx.lineWidth=1.6; 431 ctx.beginPath(); ctx.moveTo(x,y); ctx.lineTo(hx,hy); ctx.stroke(); 432 const a=Math.atan2(hy-y,hx-x); 433 ctx.beginPath(); ctx.moveTo(hx,hy); 434 ctx.lineTo(hx-6*Math.cos(a-0.4), hy-6*Math.sin(a-0.4)); 435 ctx.lineTo(hx-6*Math.cos(a+0.4), hy-6*Math.sin(a+0.4)); 436 ctx.closePath(); ctx.fill(); ctx.restore(); 437} 438function star(ctx,cx,cy,R,col){ 439 ctx.save(); ctx.beginPath(); 440 for(let i=0;i<10;i++){ 441 const rr=(i%2)?R*0.45:R, a=-Math.PI/2+i*Math.PI/5; 442 const px=cx+rr*Math.cos(a), py=cy+rr*Math.sin(a); 443 i?ctx.lineTo(px,py):ctx.moveTo(px,py); 444 } 445 ctx.closePath(); ctx.fillStyle=col; ctx.shadowColor=col; ctx.shadowBlur=8; ctx.fill(); ctx.restore(); 446} 447 448/* [pedagogy] activation helper â reveal trajectories on first interaction */ 449function activate(){ activated = true; } 450 451/* ---------------- recompute (on any parameter change) ---------------- */ 452function recompute(reason){ 453 const prevComplete = !!(vacF && linF && quaF) && tFly >= cycleT() - 1e-9; 454 const th = P.thetaDeg*RAD; 455 vacF = vacuumFlight(P.v0, th); 456 linF = dragFlight(P.v0, th, P.b, P.m); 457 quaF = quadFlight(P.v0, th, P.c, P.m); 458 actF = activeFlight(); 459 pathVac = samplePath(vacF); 460 pathLin = samplePath(linF); 461 pathQua = samplePath(quaF); 462 463 curveVac = []; curveLin = []; curveQuad = []; 464 let bd=5, bR=-1; 465 for(let d=5; d<=85; d++){ 466 const tr = d*RAD; 467 const Rv = P.v0*P.v0*Math.sin(2*tr)/g; 468 const Rl = P.b<1e-9 ? Rv : dragFlight(P.v0, tr, P.b, P.m).R; 469 const Rq = P.c<1e-9 ? Rv : quadFlight(P.v0, tr, P.c, P.m, 0.008).R; 470 curveVac.push(Rv); curveLin.push(Rl); curveQuad.push(Rq); 471 const Ra = P.model==='vac' ? Rv : (P.model==='quad' ? Rq : Rl); 472 if(Ra > bR){ bR = Ra; bd = d; } 473 } 474 opt = { deg:bd, R:bR }; 475 476 if(reason==='launch' || reason==='reset'){ 477 tFly=0; puffAge=0; 478 } else if(prevComplete && !Shell.playing){ 479 /* Stay landed on the updated paths â no auto-fire. */ 480 tFly = cycleT(); 481 } 482 483 if(reason==='theta'){ if(actF.R > best.R + 1e-12) best = { R:actF.R, deg:P.thetaDeg }; } 484 else if(reason==='launch' || reason==='reset' || reason==='env') best = { R:actF.R, deg:P.thetaDeg }; 485 486 updateHUD(); 487 drawAll(); 488} 489 490/* ---------------- HUD / panels ---------------- */ 491function updateHUD(){ 492 actF = activeFlight(); 493 const colClass = P.model==='vac' ? 'b-vac' : (P.model==='quad' ? 'b-quad' : 'b-drag'); 494 hudRange.className = 'hud-badge ' + colClass; 495 hudRange.innerHTML = `<span>Range <em>(${modelLabel()})</em></span><b>${actF.R.toFixed(2)} m</b>`; 496 hudBest.innerHTML = `<span>Best so far</span><b>${best.R.toFixed(2)} m <em>@ ${best.deg}°</em></b>`; 497 /* Legend lists all three â active solid mark, others dotted (matches scene). */ 498 const marks = [ 499 { k:'vac', span:'<span class="c-vac">â vacuum</span>', ghost:'<span class="c-vac">â vacuum</span>' }, 500 { k:'lin', span:'<span class="c-drag">â linear drag</span>', ghost:'<span class="c-drag">â linear drag</span>' }, 501 { k:'quad', span:'<span class="c-quad">â quad drag</span>', ghost:'<span class="c-quad">â quad drag</span>' } 502 ]; 503 hudLegend.innerHTML = marks.map(m => m.k===P.model ? m.span : m.ghost).join('<br>'); 504 505 if(valT) valT.textContent = actF.T.toFixed(3)+' s'; 506 if(P.model==='quad'){ 507 if(valVT) valVT.textContent = P.c>1e-9 ? actF.vT.toFixed(2)+' m/s' : 'â'; 508 if(hudTau) hudTau.style.display = 'none'; 509 } else if(P.model==='lin'){ 510 if(valVT) valVT.textContent = P.b>1e-9 ? (P.m*g/P.b).toFixed(2)+' m/s' : 'â'; 511 if(hudTau){ hudTau.style.display = ''; if(valTau) valTau.textContent = P.b>1e-9 ? (P.m/P.b).toFixed(3)+' s' : 'â'; } 512 } else { 513 if(valVT) valVT.textContent = 'â'; 514 if(hudTau){ hudTau.style.display = 'none'; } 515 } 516} 517 518/* ---------------- field drawing ---------------- */ 519function drawField(){ 520 const r = fit(fieldC, fctx), w=r.w, h=r.h, ctx=fctx; 521 if(w<40||h<40) return; 522 const light = document.body.classList.contains('light-theme'); 523 524 const gy = h-30, x0 = 46; 525 /* Fixed world window â slider changes resize the arc on screen (not scale-invariant). */ 526 const topPad = 24; /* room for apex labels */ 527 const s = Math.min((w-x0-16)/WORLD_X, (gy-18-topPad)/WORLD_Y); 528 if(!(s>0)) return; 529 const X = xm =>
529 x0 + xm*s, Y = ym => gy - ym*s; 530 531 /* sky â migrated from L4 projectile scene */ 532 const sky = ctx.createLinearGradient(0,0,0,gy); 533 if(light){ sky.addColorStop(0,'#79c1ee'); sky.addColorStop(.7,'#b6ddf6'); sky.addColorStop(1,'#ddeffb'); } 534 else { sky.addColorStop(0,'#070c1a'); sky.addColorStop(.7,'#101a30'); sky.addColorStop(1,'#182338'); } 535 ctx.fillStyle=sky; ctx.fillRect(0,0,w,gy); 536 537 if(!light){ 538 /* stars + moon (deterministic â no frame jitter) */ 539 const frac=v=>v-Math.floor(v); 540 ctx.fillStyle='#e2e8f0'; 541 for(let i=0;i<70;i++){ 542 const sx=frac(Math.sin(i*12.9898)*43758.5453)*w; 543 const sy=frac(Math.sin(i*78.233)*12543.217)*gy*.8; 544 ctx.globalAlpha=.2+.5*frac(Math.sin(i*3.7)*911.13); 545 ctx.fillRect(sx,sy,1.4,1.4); 546 } 547 ctx.globalAlpha=1; 548 const mx=w*.30, my=h*.15; 549 const glow=ctx.createRadialGradient(mx,my,4,mx,my,34); 550 glow.addColorStop(0,'rgba(226,232,240,.3)'); glow.addColorStop(1,'rgba(226,232,240,0)'); 551 ctx.fillStyle=glow; ctx.beginPath(); ctx.arc(mx,my,34,0,7); ctx.fill(); 552 ctx.fillStyle='#d7dfeb'; ctx.beginPath(); ctx.arc(mx,my,11,0,7); ctx.fill(); 553 ctx.fillStyle='rgba(148,163,184,.5)'; 554 ctx.beginPath(); ctx.arc(mx-3,my-2,2.4,0,7); ctx.fill(); 555 ctx.beginPath(); ctx.arc(mx+4,my+3,1.7,0,7); ctx.fill(); 556 } 557 558 /* clouds */ 559 function cloud(cx,cy,sc,a){ 560 ctx.fillStyle = light ? 'rgba(255,255,255,'+a+')' : 'rgba(203,213,235,'+(a*.12)+')'; 561 ctx.beginPath(); 562 ctx.ellipse(cx,cy,34*sc,12*sc,0,0,7); 563 ctx.ellipse(cx-22*sc,cy+4*sc,20*sc,9*sc,0,0,7); 564 ctx.ellipse(cx+24*sc,cy+5*sc,22*sc,9*sc,0,0,7); 565 ctx.ellipse(cx+4*sc,cy-8*sc,18*sc,10*sc,0,0,7); 566 ctx.fill(); 567 } 568 cloud(w*.16, h*.14, 1.05, .85); 569 cloud(w*.46, h*.08, .8, .7); 570 cloud(w*.60, h*.30, .6, .5); 571 cloud(w*.85, h*.22, .9, .75); 572 573 /* distant hills on the horizon */ 574 ctx.fillStyle = light ? 'rgba(126,168,104,.6)' : 'rgba(30,48,36,.85)'; 575 ctx.beginPath(); ctx.moveTo(0,gy); 576 ctx.quadraticCurveTo(w*.18, gy-h*.09, w*.38, gy); 577 ctx.quadraticCurveTo(w*.56, gy-h*.13, w*.78, gy); 578 ctx.quadraticCurveTo(w*.90, gy-h*.07, w, gy); 579 ctx.closePath(); ctx.fill(); 580 581 /* grass ground */ 582 const grass = ctx.createLinearGradient(0,gy,0,h); 583 if(light){ grass.addColorStop(0,'#6aa84f'); grass.addColorStop(1,'#43772f'); } 584 else { grass.addColorStop(0,'#22391b'); grass.addColorStop(1,'#152510'); } 585 ctx.fillStyle=grass; ctx.fillRect(0, gy, w, h-gy); 586 ctx.strokeStyle = light ? '#3c6b2b' : '#3d5c2c'; ctx.lineWidth=1.5; 587 ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(w,gy); ctx.stroke(); 588 589 /* distance ticks + labels (theme-aware) â gated by Show axes & grid */ 590 if(P.showAxes){ 591 const step = pickStep(s); 592 const gridCol = light ? 'rgba(30,41,59,.13)' : 'rgba(148,163,184,.14)'; 593 const tickCol = light ? 'rgba(51,65,85,.55)' : 'rgba(148,163,184,.55)'; 594 const labCol = light ? '#334155' : '#7d8aa0'; 595 const axisCol = light ? 'rgba(30,41,59,.45)' : 'rgba(148,163,184,.45)'; 596 const yEnd = (gy-14)/s; 597 ctx.font='12px Inter, system-ui, sans-serif'; ctx.textBaseline='top'; 598 for(let i=0;;i++){ 599 const xm=i*step, px=X(xm); 600 if(px>w-6) break; 601 ctx.strokeStyle=gridCol; 602 ctx.beginPath(); ctx.moveTo(px,14); ctx.lineTo(px,gy); ctx.stroke(); 603 ctx.strokeStyle=tickCol; 604 ctx.beginPath(); ctx.moveTo(px,gy); ctx.lineTo(px,gy+5); ctx.stroke(); 605 if(px < w-92){ ctx.fillStyle=labCol; ctx.textAlign='center'; ctx.fillText(fmtTick(xm), px, gy+9); } 606 } 607 for(let ym=step; ym<=yEnd+1e-9; ym+=step){ 608 const py=Y(ym); 609 ctx.strokeStyle=gridCol; 610 ctx.beginPath(); ctx.moveTo(x0,py); ctx.lineTo(w-6,py); ctx.stroke(); 611 ctx.fillStyle=labCol; ctx.textAlign='right'; ctx.textBaseline='middle'; 612 ctx.fillText(fmtTick(ym)+(ym+step>yEnd?' m':''), x0-6, py); 613 ctx.textBaseline='top'; 614 } 615 /* explicit x/y axes through the origin */ 616 ctx.strokeStyle=axisCol; ctx.lineWidth=1.5; 617 ctx.beginPath(); ctx.moveTo(x0,14); ctx.lineTo(x0,gy); ctx.stroke(); 618 ctx.beginPath(); ctx.moveTo(x0,gy); ctx.lineTo(w-6,gy); ctx.stroke(); 619 ctx.lineWidth=1; 620 ctx.fillStyle=light?'#475569':'#64748b'; ctx.textAlign='right'; ctx.fillText('distance (m)', w-8, gy+9); 621 } 622 623 const models = [ 624 { key:'vac', f:vacF, path:pathVac, col:COL.vac, label:'vacuum' }, 625 { key:'lin', f:linF, path:pathLin, col:COL.drag, label:'linear drag' }, 626 { key:'quad', f:quaF, path:pathQua, col:COL.quad, label:'quad drag' } 627 ]; 628 const actCol = activeColor(); 629 actF = activeFlight(); 630 631 /* [pedagogy F8a] Only draw trajectories/balls after first interaction */ 632 if(activated){ 633 // Dotted overlays first, then solid active on top (B.7 line-style contract)
634 models.forEach(m=>{ 635 if(m.key===P.model) return; 636 drawPathPts(ctx, m.path, X, Y, m.col, true); 637 drawFlag(ctx, X(m.f.R), gy, m.col, false); 638 }); 639 const actM = models.find(m=>m.key===P.model); 640 drawPathPts(ctx, actM.path, X, Y, actM.col, false); 641 drawFlag(ctx, X(actM.f.R), gy, actM.col, true); 642 643 // [physics H-EXTREME] warn when active range is a tiny fraction of the vacuum shot 644 if(actF.R < vacF.R * 0.10){ 645 drawLabel(ctx, 'â Very short range â try less drag or more mass', x0+40, gy-22, '#94a3b8', 'left', w); 646 } 647 648 // apex drop-line of the active shot (shows the asymmetry) 649 const ta=actF.tApex, ax=X(actF.x(ta)), ay=Y(Math.max(0,actF.y(ta))); 650 ctx.save(); ctx.setLineDash([3,4]); ctx.strokeStyle='rgba(226,232,240,.30)'; 651 ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(ax,gy); ctx.stroke(); ctx.restore(); 652 ctx.fillStyle=actCol; ctx.beginPath(); ctx.arc(ax,ay,2.5,0,6.29); ctx.fill(); 653 654 // On-curve labels near each apex â colour-matched, collision-nudged 655 const apexLabs = models.map(m=>{ 656 const ya = Math.max(0, m.f.y(m.f.tApex)); 657 return { t:m.label, c:m.col, x:X(m.f.x(m.f.tApex)), y:Y(ya)-10 }; 658 }).sort((a,b)=>a.y-b.y); 659 for(let i=1;i<apexLabs.length;i++){ 660 if(apexLabs[i].y - apexLabs[i-1].y < 14) apexLabs[i].y = apexLabs[i-1].y + 14; 661 } 662 apexLabs.forEach(lp=>{ 663 drawLabel(ctx, lp.t, lp.x, lp.y, lp.c, 'center', w); 664 }); 665 666 // Ghost balls (inactive) then full-size active ball â shared tFly clock 667 models.forEach(m=>{ 668 if(m.key===P.model) return; 669 const tG = Math.min(tFly, m.f.T); 670 const gx=X(m.f.x(tG)), gyy=Y(Math.max(0,m.f.y(tG))); 671 ctx.save(); ctx.setLineDash([3,3]); ctx.strokeStyle=m.col; ctx.lineWidth=1.5; ctx.globalAlpha=.7; 672 ctx.beginPath(); ctx.arc(gx,gyy,4.5,0,6.29); ctx.stroke(); ctx.restore(); 673 }); 674 const tA = Math.min(tFly, actF.T); 675 const bx=X(actF.x(tA)), by=Y(Math.max(0,actF.y(tA))); 676 if(tFly < actF.T){ 677 const vx=actF.vx(tA), vy=actF.vy(tA), sp=Math.hypot(vx,vy); 678 if(sp>0.01){ 679 const ux=vx/sp, uy=-vy/sp; 680 const L=Math.min(38, Math.max(8, sp/P.v0*32)); 681 drawArrow(ctx, bx+ux*9, by+uy*9, ux, uy, L, 'rgba(248,250,252,.7)'); 682 } 683 } 684 ctx.save(); ctx.shadowColor=actCol; ctx.shadowBlur=10; ctx.fillStyle=actCol; 685 ctx.beginPath(); ctx.arc(bx,by,6.5,0,6.29); ctx.fill(); ctx.restore(); 686 ctx.fillStyle='rgba(255,255,255,.55)'; 687 ctx.beginPath(); ctx.arc(bx-2,by-2,2,0,6.29); ctx.fill(); 688 } else { 689 /* Before first interaction: show ball at launch position only */ 690 const bx=X(0), by=Y(0); 691 ctx.save(); ctx.shadowColor=actCol; ctx.shadowBlur=10; ctx.fillStyle=actCol; 692 ctx.beginPath(); ctx.arc(bx,by,6.5,0,6.29); ctx.fill(); ctx.restore(); 693 ctx.fillStyle='rgba(255,255,255,.55)'; 694 ctx.beginPath(); ctx.arc(bx-2,by-2,2,0,6.29); ctx.fill(); 695 } 696 697 // launcher (always drawn) 698 ctx.fillStyle='#1d2937'; ctx.beginPath(); ctx.arc(x0,gy,10,Math.PI,0); ctx.fill(); 699 const th = P.thetaDeg*RAD; 700 ctx.strokeStyle='#cbd5e1'; ctx.lineWidth=5; ctx.lineCap='round'; 701 ctx.beginPath(); ctx.moveTo(x0,gy); ctx.lineTo(x0+26*Math.cos(th), gy-26*Math.sin(th)); ctx.stroke(); 702 ctx.lineWidth=1; ctx.lineCap='butt'; 703 ctx.strokeStyle='rgba(226,232,240,.45)'; 704 ctx.beginPath(); ctx.arc(x0,gy,18,-th,0); ctx.stroke(); 705 706 // muzzle puff on (re)launch 707 if(activated && puffAge >= 0 && puffAge < 0.35){ 708 const mR=Math.max(0.5, 4+puffAge*95), a=1-puffAge/0.35; 709 ctx.save(); ctx.globalAlpha=a*0.55; ctx.strokeStyle='#e2e8f0'; ctx.lineWidth=2; 710 ctx.beginPath(); ctx.arc(x0+26*Math.cos(th), gy-26*Math.sin(th), mR, 0, 6.29); ctx.stroke(); ctx.restore(); 711 } 712} 713function drawPathPts(ctx,pts,X,Y,col,dash){ 714 ctx.save(); 715 if(dash){ ctx.setLineDash([4,5]); ctx.lineWidth=1.6; ctx.globalAlpha=.55; } 716 else { ctx.lineWidth=2.4; ctx.shadowColor=col; ctx.shadowBlur=6; } 717 ctx.strokeStyle=col; ctx.beginPath(); 718 for(let i=0;i<pts.length;i++){ const px=X(pts[i][0]), py=Y(pts[i][1]); i?ctx.lineTo(px,py):ctx.moveTo(px,py); } 719 ctx.stroke(); ctx.restore(); 720} 721function drawFlag(ctx,px,gy,col,filled){ 722 ctx.save(); ctx.strokeStyle=col; ctx.lineWidth=1.5; 723 ctx.beginPath(); ctx.moveTo(px,gy);
723 ctx.lineTo(px,gy-15); ctx.stroke(); 724 ctx.beginPath(); ctx.moveTo(px,gy-15); ctx.lineTo(px+9,gy-11.5); ctx.lineTo(px,gy-8); ctx.closePath(); 725 if(filled){ ctx.fillStyle=col; ctx.fill(); } else ctx.stroke(); 726 ctx.restore(); 727} 728 729/* ---------------- range-vs-angle plot (function plot, static marker) ---------------- */ 730function drawCurve(){ 731 const r = fit(curveC, cctx), w=r.w, h=r.h, ctx=cctx; 732 ctx.fillStyle='#0a0f16'; ctx.fillRect(0,0,w,h); 733 const Lm=48, Rm=14, Tm=24, Bm=30, iw=w-Lm-Rm, ih=h-Tm-Bm; 734 if(iw<80||ih<50) return; 735 // Fixed R axis 0â95 m (v0=30 vacuum peak); θ domain stays 5â85° as a function plot. 736 const yMax = PLOT_RMAX; 737 const xOf = d => Lm + (d-5)/80*iw; 738 const yOf = v => Tm + ih - Math.max(0, Math.min(1.02, v/yMax))*ih; 739 ctx.font='12px Inter, system-ui, sans-serif'; 740 741 const ys = pickAxisStep(yMax, ih); 742 ctx.textAlign='right'; ctx.textBaseline='middle'; 743 for(let i=0; i*ys<=yMax+1e-9; i++){ 744 const v=i*ys, py=yOf(v); 745 ctx.strokeStyle='rgba(148,163,184,.10)'; 746 ctx.beginPath(); ctx.moveTo(Lm,py); ctx.lineTo(Lm+iw,py); ctx.stroke(); 747 ctx.fillStyle='#7d8aa0'; ctx.fillText(fmtTick(v), Lm-7, py); 748 } 749 ctx.textAlign='center'; ctx.textBaseline='top'; 750 for(let d=10; d<=80; d+=10){ 751 const px=xOf(d); 752 ctx.strokeStyle='rgba(148,163,184,.35)'; 753 ctx.beginPath(); ctx.moveTo(px,Tm+ih); ctx.lineTo(px,Tm+ih+4); ctx.stroke(); 754 ctx.fillStyle='#7d8aa0'; ctx.fillText(String(d), px, Tm+ih+7); 755 } 756 ctx.strokeStyle='rgba(148,163,184,.4)'; 757 ctx.beginPath(); ctx.moveTo(Lm,Tm); ctx.lineTo(Lm,Tm+ih); ctx.lineTo(Lm+iw,Tm+ih); ctx.stroke(); 758 ctx.fillStyle='#7d8aa0'; ctx.textAlign='left'; ctx.textBaseline='alphabetic'; 759 ctx.fillText('range (m)', Lm, 13); 760 ctx.textAlign='center'; ctx.fillText('launch angle θ (°)', Lm+iw/2, h-7); 761 762 // 45° reference line 763 const px45=xOf(45); 764 ctx.save(); ctx.setLineDash([4,4]); ctx.strokeStyle='rgba(148,163,184,.35)'; 765 ctx.beginPath(); ctx.moveTo(px45,Tm); ctx.lineTo(px45,Tm+ih); ctx.stroke(); ctx.restore(); 766 drawLabel(ctx,'45°', px45+4, Tm+13, '#94a3b8', 'left', w); 767 768 /* Always draw all three models for comparison */ 769 const curves = [ 770 { arr:curveVac, c:COL.vac, t:'vacuum', kind:'vac' }, 771 { arr:curveLin, c:COL.drag, t:'linear drag', kind:'lin' }, 772 { arr:curveQuad, c:COL.quad, t:'quad drag', kind:'quad' } 773 ]; 774 const activeKind = modelKind(); 775 curves.forEach(cu=>{ 776 const active = cu.kind === activeKind; 777 polyCurve(ctx, cu.arr, xOf, yOf, cu.c, !active); 778 }); 779 780 // legend (top-right) â all three 781 const items = curves.map(cu=>({ t:cu.t, c:cu.c, d:cu.kind!==activeKind })); 782 let total=0; items.forEach(it=>{ it.w=26+ctx.measureText(it.t).width; total+=it.w+14; }); 783 let lx=w-14-total+14; const lyy=11; 784 items.forEach(it=>{ 785 ctx.save(); if(it.d) ctx.setLineDash([5,4]); 786 ctx.strokeStyle=it.c; ctx.lineWidth=2; 787 ctx.beginPath(); ctx.moveTo(lx,lyy); ctx.lineTo(lx+20,lyy); ctx.stroke(); ctx.restore(); 788 ctx.fillStyle=it.c; ctx.textAlign='left'; ctx.textBaseline='middle'; 789 ctx.fillText(it.t, lx+26, lyy+0.5); 790 lx += it.w+14; 791 }); 792 793 /* On-curve labels near the right end (θ â 72°), collision-nudged */ 794 const labelDeg = 72; 795 const labelIdx = labelDeg - 5; 796 const labelPts = curves.map(cu=>{ 797 const R = cu.arr[labelIdx]; 798 return { t:cu.t, c:cu.c, x:xOf(labelDeg), y:yOf(R) }; 799 }).sort((a,b)=>a.y-b.y); 800 for(let i=1;i<labelPts.length;i++){ 801 if(labelPts[i].y - labelPts[i-1].y < 14) labelPts[i].y = labelPts[i-1].y + 14; 802 } 803 labelPts.forEach(lp=>{ 804 drawLabel(ctx, lp.t, lp.x + 6, lp.y, lp.c, 'left', w); 805 }); 806 807 // â optimum for the ACTIVE model (best whole degree) 808 const actArr = activeKind==='vac' ? curveVac : (activeKind==='quad' ? curveQuad : curveLin); 809 const sx=xOf(opt.deg), sy=yOf(opt.R); 810 star(ctx, sx, sy, 7, COL.star); 811 const bestLabelY = Math.max(24, sy-13); 812 drawLabel(ctx, 'best '+opt.deg+'°', sx, bestLabelY, COL.star, 'center', w); 813 814 // your θ â static marker bound to the slider (never self-moving) 815 const ux=xOf(P.thetaDeg), uy=yOf(actArr[P.thetaDeg-5]); 816 ctx.save(); ctx.setLineDash([3,3]); ctx.strokeStyle='rgba(255,255,255,.3)'; 817 ctx.beginPath(); ctx.moveTo(ux,uy); ctx.lineTo(ux,Tm+ih); ctx.stroke();
817 ctx.restore(); 818 ctx.fillStyle='#fff'; ctx.beginPath(); ctx.arc(ux,uy,4,0,6.29); ctx.fill(); 819 ctx.strokeStyle='rgba(10,15,22,.9)'; ctx.lineWidth=1.5; 820 ctx.beginPath(); ctx.arc(ux,uy,4,0,6.29); ctx.stroke(); 821 822 /* [visuals D5] anti-overlap: compute "you" label position, nudge if overlapping "best" */ 823 let youLabelY = uy+17; 824 if(youLabelY > Tm+ih-2) youLabelY = uy-10; 825 // Check overlap with "best" label 826 if(Math.abs(ux - sx) < 55 && Math.abs(youLabelY - bestLabelY) < 16){ 827 // Nudge to opposite side of dot 828 youLabelY = uy > sy ? Math.min(uy+17, Tm+ih-10) : Math.max(uy-17, 24); 829 // If still overlapping, push further 830 if(Math.abs(youLabelY - bestLabelY) < 16){ 831 youLabelY = bestLabelY > Tm + ih/2 ? Math.max(bestLabelY - 20, 24) : Math.min(bestLabelY + 20, Tm+ih-10); 832 } 833 } 834 drawLabel(ctx, 'you '+P.thetaDeg+'°', ux, youLabelY, '#f8fafc', 'center', w); 835} 836function polyCurve(ctx,arr,xOf,yOf,col,dash){ 837 ctx.save(); 838 if(dash){ ctx.setLineDash([6,5]); ctx.lineWidth=1.5; ctx.globalAlpha=.75; } 839 else ctx.lineWidth=2.2; 840 ctx.strokeStyle=col; ctx.beginPath(); 841 for(let i=0;i<arr.length;i++){ const px=xOf(5+i), py=yOf(arr[i]); i?ctx.lineTo(px,py):ctx.moveTo(px,py); } 842 ctx.stroke(); ctx.restore(); 843} 844 845function drawAll(){ if(!actF) return; drawField(); if(curveOpen) drawCurve(); } 846 847/* ---------------- curve panel toggle (explicit reveal control) ---------------- */ 848function setCurve(on){ 849 if(curveOpen===on) return; 850 curveOpen = on; 851 heroWrap.classList.toggle('show-curve', on); 852 btnCurve.classList.toggle('active', on); 853 btnCurve.textContent = on ? 'ð Hide curve' : 'ð Range vs angle'; 854 requestAnimationFrame(()=>Shell.refit()); 855} 856 857/* ---------------- controls ---------------- */ 858function refreshVals(){ 859 vTh.textContent = P.thetaDeg+'°'; 860 vV.textContent = P.v0+' m/s'; 861 vB.textContent = P.b.toFixed(2)+' kg/s'; 862 if(vC) vC.textContent = P.c.toFixed(3)+' kg/m'; 863 vM.textContent = P.m.toFixed(1)+' kg'; 864} 865function styleModes(){ 866 const isDrag = P.model!=='vac'; 867 mVac.classList.toggle('active', !isDrag); 868 mDrag.classList.toggle('active', isDrag); 869 if(dragKindRow) dragKindRow.classList.toggle('visible', isDrag); 870 if(kindLin) kindLin.classList.toggle('active', P.model==='lin'); 871 if(kindQuad) kindQuad.classList.toggle('active', P.model==='quad'); 872} 873function setModel(m){ 874 if(P.model===m) return; 875 P.model=m; 876 if(m==='lin' || m==='quad') lastDrag = m; 877 styleModes(); 878 activate(); 879 /* Restyle only â rebuild opt/HUD from existing curves; no relaunch. */ 880 actF = activeFlight(); 881 const arr = P.model==='vac' ? curveVac : (P.model==='quad' ? curveQuad : curveLin); 882 if(arr.length){ 883 let bd=5, bR=-1; 884 for(let i=0;i<arr.length;i++){ if(arr[i]>bR){ bR=arr[i]; bd=5+i; } } 885 opt = { deg:bd, R:bR }; 886 } 887 updateHUD(); 888 drawAll(); 889} 890function doLaunch(){ 891 activate(); 892 recompute('launch'); 893 Shell.setPlaying(true); 894} 895slTh.addEventListener('input', ()=>{ 896 P.thetaDeg=Math.round(+slTh.value); 897 refreshVals(); 898 activate(); 899 recompute('theta'); 900}); 901slV .addEventListener('input', ()=>{ P.v0=Math.round(+slV.value); refreshVals(); activate(); recompute('env'); }); 902slB .addEventListener('input', ()=>{ P.b=+slB.value; refreshVals(); activate(); recompute('env'); }); 903slC .addEventListener('input', ()=>{ P.c=+slC.value; refreshVals(); activate(); recompute('env'); }); 904slM .addEventListener('input', ()=>{ P.m=+slM.value; refreshVals(); activate(); recompute('env'); }); 905mVac .addEventListener('click', ()=>setModel('vac')); 906mDrag.addEventListener('click', ()=>{ 907 /* Enter drag mode â restore last Linear/Quadratic choice. */ 908 if(P.model==='vac') setModel(lastDrag); 909}); 910kindLin.addEventListener('click', ()=>setModel('lin')); 911kindQuad.addEventListener('click', ()=>setModel('quad')); 912btnLaunch.addEventListener('click', doLaunch); 913btnCurve.addEventListener('click', ()=>setCurve(!curveOpen)); 914$('gridToggle').addEventListener('change', e=>{ 915 P.showAxes = !!e.target.checked; 916 drawField(); 917}); 918 919/* ---------------- shell callbacks ---------------- */ 920function onFrame(dt){ 921 if(!actF) return; 922 /* [pedagogy F8a] mark as activated on first frame */ 923 activate(); 924 dt = Math.max(0, dt); 925 puffAge = Math.max(0, puffAge + dt); 926 const CT = cycleT(); 927 if(tFly >= CT){ 928 tFly = CT; 929 if(Shell.playing) Shell.setPlaying(false); /* land â â¶ Play (replay) */ 930 } else { 931 tFly = Math.min(CT, tFly+dt); 932 } 933 drawAll(); 934} 935function onReset(){ 936 P = { ...DEF }; 937 lastDrag = (P.model==='quad') ? 'quad' : 'lin'; 938 slTh.value=P.thetaDeg; slV.value=P.v0; slB.value=P.b; slC.value=P.c; slM.value=P.m; 939 const gt=$('gridToggle'); if(gt) gt.checked=!!P.showAxes; 940 refreshVals(); styleModes(); 941 setCurve(false); 942 tFly=0; puffAge=0; 943 recompute('reset'); 944} 945function onResize(){ drawAll(); } 946// Completed / skipped inquiry: open live with trajectories visible (nothing is gated). 947function onComplete(){ activated = true; setCurve(false); drawAll(); } 948 949/* ---------------- guided-inquiry scene driver ---------------- 950 Deterministic + self-contained: each call fully sets the state for card i so 951 Skip/Finish fast-forward and revisits restore the matching scene. */ 952function applyInquiryScene(cfg){ 953 /* Accept model:'vac'|'lin'|'quad', or legacy mode/dragKind from older cards. */ 954 if(cfg.model) P.model = cfg.model; 955 else if(cfg.mode==='vacuum') P.model = 'vac'; 956 else P.model = (cfg.dragKind==='quad') ? 'quad' : 'lin'; 957 if(P.model==='lin' || P.model==='quad') lastDrag = P.model; 958 P.thetaDeg = cfg.theta; 959 P.v0 = cfg.v0 != null ? cfg.v0 : DEF.v0; 960 P.b = cfg.b != null ? cfg.b : DEF.b; 961 P.c = cfg.c != null ? cfg.c : DEF.c; 962 P.m = cfg.m != null ? cfg.m : DEF.m; 963 slTh.value = P.thetaDeg; 964 slV.value = P.v0; 965 slB.value = P.b; 966 slC.value = P.c; 967 slM.value = P.m; 968 refreshVals(); 969 styleModes(); 970 setCurve(!!cfg.curve); 971 activated = true; 972 if(cfg.playing){
973 recompute('launch'); 974 Shell.setPlaying(true); 975 } else { 976 recompute('env'); 977 Shell.setPlaying(false); 978 tFly = cycleT(); /* pause on the landed scene for this setup */ 979 } 980} 981function onStep(i){ 982 // 0 Ground â three-model race at 45° (asymmetric arcs visible) 983 if(i===0) applyInquiryScene({ model:'lin', theta:45, b:0.30, curve:false, playing:true }); 984 // 1 Predict â pause on the same setup so the question is about what you just saw 985 else if(i===1) applyInquiryScene({ model:'lin', theta:45, b:0.30, curve:false, playing:false }); 986 // 2 Hunt â live again; student sweeps θ from 45° 987 else if(i===2) applyInquiryScene({ model:'lin', theta:45, b:0.30, curve:false, playing:true }); 988 // 3 Curve â open the plot near the computed optimum (~39° at defaults) 989 else if(i===3) applyInquiryScene({ model:'lin', theta:39, b:0.30, curve:true, playing:true }); 990 // 4 Resolve / lecture landing scene â same defaults as Reset (lecture mode jumps here) 991 else if(i===4) applyInquiryScene({ model:'lin', theta:DEF.thetaDeg, v0:DEF.v0, b:DEF.b, c:DEF.c, m:DEF.m, curve:false, playing:true }); 992} 993 994/* ---------------- guided-inquiry prediction feedback ---------------- */ 995document.querySelectorAll('#inq-cards .inq-step').forEach(card=>{ 996 const evalBox=card.querySelector('.predict-eval'); 997 const choices=card.querySelectorAll('.choice'); 998 choices.forEach(ch=>ch.addEventListener('click',()=>{ 999 if(card.dataset.answered) return; card.dataset.answered='1'; 1000 const correct=ch.dataset.correct==='1'; 1001 choices.forEach(c=>{ c.disabled=true; 1002 if(c.dataset.correct==='1') c.classList.add('correct'); 1003 else if(c===ch) c.classList.add('wrong'); else c.classList.add('dim'); }); 1004 if(evalBox){ evalBox.innerHTML=ch.dataset.fb||''; evalBox.classList.add('show', correct?'correct':'wrong'); } 1005 })); 1006}); 1007 1008/* ---------------- KaTeX ---------------- */ 1009function renderMath(){ 1010 if(!window.katex){ setTimeout(renderMath, 80); return; } 1011 document.querySelectorAll('.ktx').forEach(el=>{ 1012 try{ katex.render(el.dataset.tex, el, { throwOnError:false, displayMode:false }); } 1013 catch(err){ el.textContent = el.dataset.tex; } 1014 }); 1015} 1016renderMath(); 1017 1018/* ---------------- boot ---------------- */ 1019Shell.init({ onFrame, onReset, onResize, onStep, onComplete }); 1020/* After landing, â¶ Play replays from t = 0 (shell toggle already flipped playing on). */ 1021$('shell-play').addEventListener('click', ()=>{ 1022 if(flightComplete() && Shell.playing){ 1023 tFly = 0; 1024 puffAge = 0; 1025 } 1026}); 1027 1028/* ---------------- machine-readable audit hook (classical mechanics â no Lorentz γ) ---------------- 1029 Numbers displayed by the sim equal these formulas at the same slider inputs (SI, g = 9.81). */ 1030window.__audit = { 1031 at(x){ 1032 const thetaRad = P.thetaDeg*RAD, v0 = P.v0; 1033 const d = dragFlight(v0, thetaRad, P.b, P.m); 1034 let bd=5, bR=-1; 1035 for(let dd=5; dd<=85; dd++){ 1036 const R = dragFlight(v0, dd*RAD, P.b, P.m).R; 1037 if(R > bR){ bR=R; bd=dd; } 1038 } 1039 const q = quadFlight(v0, thetaRad, P.c, P.m); 1040 let qd=5, qR=-1; 1041 for(let dd=5; dd<=85; dd++){ 1042 const R = quadFlight(v0, dd*RAD, P.c, P.m, 0.008).R; 1043 if(R > qR){ qR=R; qd=dd; } 1044 } 1045 return { 1046 vacuumRange: v0*v0*Math.sin(2*thetaRad)/9.81, // v0, thetaRad = current slider values 1047 vacuumFlightTime: 2*v0*Math.sin(thetaRad)/9.81, 1048 dragRange: d.R, 1049 dragFlightTime: d.T, 1050 optimalDragAngleDeg: bd, // best whole degree, drag model 1051 terminalVelocity: P.b>1e-9 ? P.m*9.81/P.b : null, // mg/b 1052 quadRange: q.R, 1053 quadFlightTime: q.T, 1054 optimalQuadAngleDeg: qd, 1055 terminalVelocityQuad: P.c>1e-9 ? Math.sqrt(P.m*9.81/P.c) : null 1056 }; 1057 } 1058}; 1059 1060})(); 1061; 1062/* Open in Lecture mode by default â trigger the shell's lecture toggle once on load. */ 1063requestAnimationFrame(function(){var b=document.getElementById('shell-lecture'),r=document.getElementById('shell');if(b&&r&&!r.classList.contains('lecture-mode'))b.click();});
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.