PageSourceSearch

https://fermi.ai/hp-v1-assets/sim.js

js fermi.ai collected 2026-10-05 11:59:57 UTC 45,764 bytes, 1,063 lines download raw bytes

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.