PageSourceSearch

https://littlebirdelectronics.com.au/assets/controllers/courses/network_lab_controller-80063cf1.js

js littlebirdelectronics.com.au collected 2026-10-03 19:16:59 UTC 9,524 bytes, 92 lines download raw bytes

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.