1import { Howl } from 'howler' 2import { Controller } from '@hotwired/stimulus' 3import { initialState, experiment } from 'src/courses/network_lab_engine' 4import { journeyKey, teachingScript } from 'src/courses/network_lab_narration' 5import { diagram } from 'src/courses/network_lab_diagrams' 6export default class extends Controller { 7 static values = { config: Object } 8 static targets = ['path','explanation','step','status','feedback','play','previous','progress','voice','audioStatus','transcript'] 9 connect() { 10 this.version=0;this.recent=[];this.state=initialState(this.configValue);this.position=-1;this.result=null;this.narration=true;this.playing=false;this.element.networkLab=this 11 this.onVisibility=()=>{if(document.hidden)this.stopAnimation()};document.addEventListener('visibilitychange',this.onVisibility) 12 this.observer=new IntersectionObserver(entries=>{if(!entries[0].isIntersecting)this.stopAnimation()});this.observer.observe(this.element);this.render() 13 } 14 disconnect() { this.stopAnimation();this.disposeAudio();this.observer.disconnect();document.removeEventListener('visibilitychange',this.onVisibility);delete this.element.networkLab } 15 disposeAudio(){clearInterval(this.cueTimer);this.audioToken=(this.audioToken||0)+1;this.sound?.unload();this.sound=null} 16 stopAnimation(){clearTimeout(this.timer);clearInterval(this.cueTimer);this.timer=null;this.playing=false;this.sound?.pause();this.element.querySelectorAll('.nl-map').forEach(svg=>svg.pauseAnimations?.());this.updateControls()} 17 animate(){ 18 if(this.playing){this.stopAnimation();return} 19 document.querySelectorAll('.nl-lab').forEach(el=>{if(el!==this.element)el.networkLab?.stopAnimation()}) 20 if(!this.result||this.position>=this.result.stages.length-1&&this.finished){this.disposeAudio();this.result=experiment(this.configValue.id,this.state);this.position=0;this.finished=false;this.log(`Step 1: ${this.result.stages[0].title}`);this.render()} 21 this.playing=true;this.element.querySelectorAll('.nl-map').forEach(svg=>svg.unpauseAnimations?.());this.updateControls();this.playStage() 22 } 23 playStage(){ 24 clearTimeout(this.timer) 25 const advance=()=>{if(!this.playing)return;this.disposeAudio();if(this.position>=this.result.stages.length-1){this.finished=true;this.stopAnimation();return}this.position++;this.log(`Step ${this.position+1}: ${this.result.stages[this.position].title}`);this.render();this.playStage()} 26 if(this.narration){ 27 const journey=this.configValue.journeys?.[journeyKey(this.result)] 28 if(journey){ 29 const follow=()=>{if(!this.playing||!this.sound)return;const time=Number(this.sound.seek())||0;let position=0;journey.cues.forEach((cue,i)=>{if(time>=cue)position=i});if(position!==this.position){this.position=position;this.log(`Step ${position+1}: ${this.result.stages[position].title}`);this.render()}} 30 clearInterval(this.cueTimer) 31 if(this.sound){this.sound.play();return} 32 const token=this.audioToken 33 const fallback=()=>{if(token!==this.audioToken||!this.playing)return;clearInterval(this.cueTimer);this.disposeAudio();this.audioStatusTarget.textContent='Audio unavailable · continuing with captions';this.timer=setTimeout(advance,6000)} 34 this.sound=new Howl({src:[journey.src],html5:true,preload:true, 35 onload:()=>{if(token!==this.audioToken)return;this.sound.seek(journey.cues[this.position]||0);if(this.playing)this.sound.play()}, 36 onplay:()=>{if(token!==this.audioToken)return;clearInterval(this.cueTimer);this.cueTimer=setInterval(follow,100)}, 37 onend:()=>{if(token!==this.audioToken||!this.playing)return;this.position=this.result.stages.length-1;this.finished=true;this.stopAnimation();this.render();this.disposeAudio()}, 38 onloaderror:fallback,onplayerror:fallback}) 39 return 40 } 41 this.audioStatusTarget.textContent='No voice-over for this journey · follow the captions' 42 } 43 const stage=this.result.stages[this.position] 44 this.timer=setTimeout(advance,Math.max(3500,(stage.detail.split(/\s+/).length/3)*1000)) 45 } 46 toggleVoice(){this.narration=!this.narration;this.disposeAudio();this.updateControls();if(this.playing)this.playStage()} 47 previous(){this.stopAnimation();this.disposeAudio();if(this.position>0){this.position--;this.finished=false;this.log('Previous step');this.render()}} 48 updateControls(){ 49 if(!this.hasPlayTarget)return 50 this.playTarget.textContent=this.playing?'⠡ Pause explanation':this.finished?'⻠Replay explanation':this.position>=0?'ⶠContinue explanation':'ⶠPlay explanation' 51 this.playTarget.setAttribute('aria-pressed',String(this.playing)) 52 this.voiceTarget.textContent=this.narration?'Voice-over: on':'Voice-over: off';this.voiceTarget.setAttribute('aria-pressed',String(this.narration)) 53 this.audioStatusTarget.textContent=this.narration?'Voice-over on · follows each step':'Silent animation · read the captions' 54 this.previousTarget.disabled=this.position<=0 55 this.stepTarget.disabled=!!this.result&&this.position===this.result.stages.length-1 56 this.progressTarget.textContent=this.result?`${this.position+1} / ${this.result.stages.length}`:'Step by step' 57 } 58 log(action) { this.version++;this.recent=[...this.recent,action].slice(-6) } 59 change(event) { this.set(event.target.dataset.setting,event.target.value) } 60 choose(event) { this.set(event.currentTarget.dataset.key,event.currentTarget.dataset.value) } 61 set(key,value) { 62 const control=this.configValue.controls.find(c=>c.key===key) 63 if(!control?.options.some(o=>o.value===value))return false 64 this.stopAnimation();this.disposeAudio();this.finished=false 65 this.state.settings[key]=value 66 this.element.querySelectorAll(`[data-key="${key}"]`).forEach(el=>el.setAttribute("aria-pressed",String(el.dataset.value===value))) 67 this.result=null;this.position=-1;this.log(`Set ${control.label}: ${value}`);this.render();return true 68 } 69 step() { this.stopAnimation();this.disposeAudio();if(!this.result){this.result=experiment(this.configValue.id,this.state);this.position=-1}if(this.position<this.result.stages.length-1)this.position++;this.finished=false;this.log(`Step ${this.position+1}: ${this.result.stages[this.position].title}`);this.render() } 70 run() { this.stopAnimation();this.disposeAudio();this.result=experiment(this.configValue.id,this.state);this.position=this.result.stages.length-1;this.finished=true;this.log(`Run: ${this.result.result}`);this.render();this.transcriptTarget.open=true } 71 advance() { if(this.configValue.id!=='cache')return;this.stopAnimation();this.disposeAudio();this.state.time+=30;this.result=null;this.position=-1;this.finished=false;this.log('Advance 30 seconds');this.render() } 72 reset() { this.stopAnimation();this.disposeAudio();this.state=initialState(this.configValue);this.result=null;this.position=-1;this.finished=false;
72this.element.querySelectorAll('[data-key]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.value===this.state.settings[e.dataset.key])));this.feedbackTarget.textContent='';this.log('Reset experiment');this.render() } 73 render(full=false) { 74 this.pathTarget.replaceChildren() 75 const stages=this.result?.stages||[{node:'Your settings'},{node:'Follow the request'},{node:'Explain the result'}] 76 this.pathTarget.append(diagram(this.configValue.id,this.state,this.result,this.position)) 77 this.explanationTarget.replaceChildren() 78 const title=document.createElement('strong'),body=document.createElement('p') 79 const current=this.result?.stages[this.position] 80 title.textContent=current?.title||'Make a prediction first.';body.textContent=current?.detail||'Choose your settings. Where will the request go, and why?';this.explanationTarget.append(title,body) 81 const list=this.transcriptTarget.querySelector('ol');list.replaceChildren() 82 const transcript=this.result||experiment(this.configValue.id,structuredClone(this.state)) 83 teachingScript(this.configValue.id,transcript).forEach(text=>{const li=document.createElement('li');li.textContent=text;list.append(li)}) 84 this.updateControls() 85 const cache=this.configValue.id==='cache'?`Clock: ${this.state.time}s · TTL remaining: ${Math.max(0,this.state.expires-this.state.time)}s · Upstream requests: ${this.state.upstream}. `:'' 86 this.statusTarget.textContent=cache+(this.result?`Step ${this.position+1} of ${stages.length}${this.position===stages.length-1?' · '+this.result.result:''}`:'Ready to explore') 87 } 88 answer(event){const correct=Number(event.currentTarget.dataset.answer)===this.configValue.challenge.correct;this.feedbackTarget.dataset.correct=String(correct);this.feedbackTarget.textContent=(correct?'Correct. ':'Try again. ')+this.configValue.challenge.reason;this.log(correct?'Understanding check correct':'Understanding check retry')} 89 snapshot(){return {id:this.configValue.id,settings:{...this.state.settings},version:this.version,time:this.state.time,step:this.position+1,observation:this.explanationTarget.textContent+' '+this.statusTarget.textContent,recent_actions:[...this.recent]}} 90 apply(action){if(action.version!==this.version)return false;if(action.action==='set')return this.set(action.key,action.value);if(!['step','run','reset','advance'].includes(action.action))return false;this[action.action]();return true} 91 askTutor(){document.dispatchEvent(new CustomEvent('courses:lab-question',{detail:{question:`Explain my current result in â${this.configValue.title}â. Help me predict one change before showing it.`}}))} 92}
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.