arff-instructor / app.js
Superboss1976's picture
Build AM Risk academy with Prof, persistent progress and instructor review desk
8896856 verified
Raw History Blame Contribute Delete
19 kB
import { lessons } from './lessons.js';
import { sources } from './sources.js';
import { readState, saveSessions, storageAvailable, freshSession } from './state.js';
const $=id=>document.getElementById(id);
let requestedLesson=Number(new URLSearchParams(location.search).get('lesson'));
let current=Number.isInteger(requestedLesson)&&requestedLesson>=0&&requestedLesson<lessons.length?requestedLesson:0,airfield=null,instructor=null,muted=false,paused=matchMedia('(prefers-reduced-motion: reduce)').matches;
const fresh=freshSession;
const sessions=readState().sessions;
function persist(completed=null){saveSessions(sessions,completed);$('exercise-note').textContent=storageAvailable()?'Progress saved on this device · Practice exercise':'Browser storage unavailable · Keep a copy of your debrief';}
let narration='',voiceList=[],speechId=0,startTimer=null,endTimer=null;
const speechSupported='speechSynthesis' in window && 'SpeechSynthesisUtterance' in window;
function el(tag,className,text){const n=document.createElement(tag);if(className)n.className=className;if(text!==undefined)n.textContent=text;return n;}
function voiceVisual(active){instructor?.speak(active);$('speaker-icon').classList.toggle('active',active);$('prof-portrait').classList.toggle('speaking',active);}
function stopVoice(){speechId++;clearTimeout(startTimer);clearTimeout(endTimer);if(speechSupported)window.speechSynthesis.cancel();voiceVisual(false);}
function speak(text){
narration=text;$('instructor-text').textContent=text;stopVoice();
if(muted){$('voice-status').textContent='NARRATION MUTED · TEXT AVAILABLE';return;}
if(!speechSupported){$('voice-status').textContent='TEXT MODE · DEVICE VOICE UNAVAILABLE';return;}
const id=speechId,utterance=new SpeechSynthesisUtterance(text);utterance.lang='en-GB';utterance.rate=.94;utterance.pitch=.94;
const selected=voiceList.find(v=>v.voiceURI===$('voice-select').value)||voiceList.find(v=>/^en[-_]ZA/i.test(v.lang))||voiceList.find(v=>/^en[-_]GB/i.test(v.lang))||voiceList.find(v=>/^en/i.test(v.lang));
if(selected){utterance.voice=selected;utterance.lang=selected.lang;}
$('voice-status').textContent='STARTING NARRATION';
utterance.onstart=()=>{if(id!==speechId)return;clearTimeout(startTimer);$('voice-status').textContent='INSTRUCTOR SPEAKING';voiceVisual(true);};
utterance.onend=()=>{if(id!==speechId)return;clearTimeout(startTimer);clearTimeout(endTimer);voiceVisual(false);$('voice-status').textContent='YOUR TURN';};
utterance.onerror=()=>{if(id!==speechId)return;clearTimeout(startTimer);clearTimeout(endTimer);voiceVisual(false);$('voice-status').textContent='VOICE UNAVAILABLE · READ THE TEXT';};
startTimer=setTimeout(()=>{if(id===speechId && !window.speechSynthesis.speaking){$('voice-status').textContent='VOICE UNAVAILABLE · READ THE TEXT';voiceVisual(false);}},3500);
endTimer=setTimeout(()=>{if(id===speechId){stopVoice();$('voice-status').textContent='NARRATION STOPPED · REPLAY AVAILABLE';}},Math.max(30000,text.length*130));
window.speechSynthesis.speak(utterance);
}
function loadVoices(){if(!speechSupported)return;const previous=$('voice-select').value;voiceList=window.speechSynthesis.getVoices();$('voice-select').replaceChildren(el('option','','Automatic English voice'));$('voice-select').firstChild.value='';voiceList.filter(v=>/^en/i.test(v.lang)).forEach(v=>{const o=el('option','',`${v.name} (${v.lang})`);o.value=v.voiceURI;$('voice-select').append(o);});$('voice-select').value=previous;}
if(speechSupported){loadVoices();window.speechSynthesis.addEventListener('voiceschanged',loadVoices);}
function renderMissions(){
$('mission-list').replaceChildren();
lessons.forEach((lesson,i)=>{const b=el('button','mission-button'+(i===current?' current':''));b.setAttribute('aria-pressed',String(i===current));b.append(el('b','',String(i+1).padStart(2,'0')));const label=el('span','',lesson.title);label.append(el('small','',lesson.level+' · '+lesson.aim));b.append(label);b.append(el('em','',sessions[i].ended?'✓':i===current?'→':''));b.addEventListener('click',()=>selectMission(i));$('mission-list').append(b);});
$('session-count').textContent=`${sessions.filter(s=>s.ended).length} / 3`;
}
function render(){
const lesson=lessons[current],s=sessions[current];renderMissions();$('mission-number').textContent=`0${current+1} / 03`;$('scene-title').textContent=lesson.title;$('scene-subtitle').textContent=lesson.subtitle;
$('progress').value=s.answers.length;$('progress-label').textContent=s.ended?'Complete':s.started?`${s.answers.length} / 3 decisions`:'Not started';
$('observations').hidden=s.started;$('choices').hidden=!s.started||s.ended;$('feedback').hidden=true;$('debrief').hidden=!s.ended;
$('start-button').textContent=s.started?'▶ Read current step':'▶ Start briefing';
[...document.querySelectorAll('[data-observe]')].forEach((b,i)=>{b.children[1].firstChild.textContent=lesson.observations[i].title;});
if(!s.started){$('step-label').textContent='FIRST, GET YOUR BEARINGS';$('step-count').textContent='BRIEFING';$('exercise-title').textContent='Three observations. One clearer picture.';$('exercise-description').textContent='Explore the three observation points, then start your briefing. This is a tabletop practice exercise.';$('instructor-text').textContent=lesson.intro;$('voice-status').textContent='READY TO BRIEF';narration=lesson.intro;return;}
if(s.ended){renderDebrief();return;}
const step=lesson.steps[s.index];$('step-label').textContent=lesson.level.toUpperCase()+' / '+step.topic.toUpperCase();$('step-count').textContent=`${s.index+1} / 3`;$('exercise-title').textContent=step.title;$('exercise-description').textContent=step.prompt;
$('choices').replaceChildren();const answer=s.answers[s.index];step.options.forEach((option,i)=>{const b=el('button','choice');b.append(el('span','',String.fromCharCode(65+i)),document.createTextNode(option.text));b.disabled=Boolean(answer);if(answer){if(i===answer.choice)b.classList.add('chosen');if(option.correct)b.classList.add('correct');}b.addEventListener('click',()=>answerQuestion(i));$('choices').append(b);});
if(answer)renderFeedback(step.options[answer.choice]);
else{narration=step.title+' '+step.prompt;$('instructor-text').textContent=narration;$('voice-status').textContent='CHOOSE YOUR RESPONSE';}
}
function selectMission(i){stopVoice();current=i;history.replaceState(null,'',`simulator.html?lesson=${i}`);$('scene-callout').hidden=true;document.querySelectorAll('[data-observe]').forEach(b=>b.classList.remove('selected'));airfield?.setMission(i);airfield?.observe(-1);render();}
function begin(){const s=sessions[current];if(!s.started){s.started=true;persist();render();speak(lessons[current].intro+' '+lessons[current].steps[0].title+' '+lessons[current].steps[0].prompt);}else{speak(narration);}}
function answerQuestion(choice){const s=sessions[current];if(s.ended||s.answers[s.index])return;const option=lessons[current].steps[s.index].options[choice];s.answers.push({choice,correct:option.correct});persist();airfield?.setEffect(option.effect);$('scene-callout').hidden=true;render();speak((option.correct?'Good reasoning. ':'Let’s review that. ')+option.feedback);$('next-button').focus({preventScroll:true});}
function renderFeedback(option){$('feedback').hidden=false;$('feedback-title').textContent=option.correct?'✓ Clear reasoning':'Let’s review that choice';$('feedback-text').textContent=option.feedback;$('consequence-text').textContent='Exercise consequence: '+option.consequence;$('next-button').textContent=sessions[current].index===2?'View my debrief →':'Next decision →';narration=option.feedback;$('instructor-text').textContent=narration;}
function next(){const s=sessions[current];if(!s.answers[s.index])return;if(s.index===2){s.ended=true;persist(current);render();speak(narration);}else{s.index++;persist();render();speak(narration);$('choices').firstChild.focus({preventScroll:true});}}
function renderDebrief(){
const s=sessions[current],lesson=lessons[current],score=s.answers.filter(a=>a.correct).length;
$('step-label').textContent='EXERCISE COMPLETE';$('step-count').textContent='DEBRIEF';$('exercise-title').textContent='Every decision is a learning point.';$('exercise-description').textContent='Review your first choices and take the record to your instructor. This is practice evidence, not a qualification.';
const root=$('debrief');root.replaceChildren();const line=el('div','score-line');line.append(el('div','score-number',`${score}/3`));const label=el('div');label.append(el('strong','',score===3?'Clear reasoning throughout':'Useful points to revisit'));label.append(el('p','',score===3?'You identified the strongest responses in this exercise.':'Use the feedback below to guide your next practice session.'));line.append(label);root.append(line);
lesson.steps.forEach((step,i)=>{const a=s.answers[i],option=step.options[a.choice];const row=el('div','review-item');row.append(el('strong','',`${a.correct?'✓':'↻'} ${step.topic}`),el('span','',`Your choice: ${option.text}`),el('p','',option.feedback));root.append(row);});
const actions=el('div','debrief-actions'),download=el('button','primary','↗ View / save debrief');download.id='download-button';download.addEventListener('click',downloadDebrief);actions.append(download);if(current<2){const b=el('button','quiet-button','Next exercise →');b.addEventListener('click',()=>{selectMission(current+1);begin();});actions.append(b);}root.append(actions);
narration=`Exercise complete. You selected ${score} of the three strongest responses. ${score===3?'Good attention to the information in the scene.':'Review the explanations and discuss any gaps with your instructor.'} Your debrief records the choices you made. This is a practice record, not a qualification.`;$('instructor-text').textContent=narration;$('voice-status').textContent='DEBRIEF READY';
}
function downloadDebrief(){const lesson=lessons[current],s=sessions[current];if(!s.ended)return;const lines=['AM RISK & TRAINING — ARFF TRAINING LAB','PRACTICE RECORD — NOT A CERTIFICATE',new Date().toISOString(),'',`Exercise: ${lesson.title}`,`First-choice score: ${s.answers.filter(a=>a.correct).length}/3`,''];lesson.steps.forEach((step,i)=>{const a=s.answers[i],o=step.options[a.choice];lines.push(`${i+1}. ${step.title}`,`Your choice: ${o.text}`,`Outcome: ${a.correct?'Strongest response':'Review recommended'}`,`Feedback: ${o.feedback}`,`Exercise consequence: ${o.consequence}`,'');});lines.push('Instructor discussion:','- What information was missing?','- Which local procedures apply?','- What practical training or assessment should follow?','','Scope: Authored tabletop demo. Not an operational tool, accredited course or assessment of practical competence. Aircraft and scene are illustrative.','Official reference library:',...sources.map(x=>`${x.name}: ${x.url}`));const report=lines.join('\n');stopVoice();$('dialog-label').textContent='YOUR PRACTICE RECORD';const root=$('dialog-content');root.replaceChildren(el('h2','','Keep your learning record.'),el('p','','Copy your debrief or save it as a text file. If your browser does not support the download, select and copy the text below.'));const preview=el('textarea','report-preview');preview.id='report-preview';preview.readOnly=true;preview.value=report;preview.setAttribute('aria-label','Your practice debrief');root.append(preview);const actions=el('div','debrief-actions'),copy=el('button','primary','Copy debrief'),save=el('button','quiet-button','↓ Save text file');copy.id='copy-report';copy.addEventListener('click',async()=>{try{await navigator.clipboard.writeText(report);copy.textContent='✓ Copied';}catch{preview.focus();preview.select();copy.textContent='Text selected — copy now';}});save.id='save-report';save.addEventListener('click',()=>{const blob=new Blob([report],{type:'text/plain;charset=utf-8'}),url=URL.createObjectURL(blob),a=el('a');a.href=url;a.download=`AM-ARFF-${current+1}-practice-debrief.txt`;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),30000);});actions.append(copy,save);root.append(actions);$('info-dialog').showModal();}
function observe(i){const o=lessons[current].observations[i];$('callout-label').textContent=`0${i+1} / ${o.title.toUpperCase()}`;$('callout-text').textContent=o.text;$('scene-callout').hidden=false;document.querySelectorAll('[data-observe]').forEach((b,j)=>b.classList.toggle('selected',i===j));airfield?.observe(i);speak(o.text);}
function showDialog(kind){stopVoice();const root=$('dialog-content');root.replaceChildren();
if(kind==='sources'){$('dialog-label').textContent='OFFICIAL SOURCES / SCOPE OF THE DEMO';root.append(el('h2','','Build knowledge on evidence.'),el('p','','These are reference routes for a future instructor-reviewed programme. The demo exercises are authored practice questions; they have not been mapped to or approved by all five bodies.'),el('p','source-notice','ICAO, NFPA, EASA, SACAA and UK CAA serve different roles and jurisdictions. Their requirements and qualifications are not interchangeable.'));
sources.forEach(source=>{const item=el('div','source-item'),a=el('a','',`${source.name} — ${source.title} ↗`);a.href=source.url;a.target='_blank';a.rel='noopener noreferrer';item.append(a,el('p','',source.scope),el('small','',source.status));root.append(item);});
}else if(kind==='notes'){$('dialog-label').textContent='LEARNING NOTES / '+lessons[current].level.toUpperCase();root.append(el('h2','',lessons[current].title),el('p','',lessons[current].intro));lessons[current].steps.forEach(step=>{root.append(el('h3','',step.topic),el('p','',step.options.find(o=>o.correct).feedback));});root.append(el('p','source-notice','These are authored learning notes, not quoted regulatory clauses. Confirm local procedures and current requirements with your qualified instructor.'));
}else{$('dialog-label').textContent='ABOUT THIS PROTOTYPE';root.append(el('h2','','A training concept you can actually try.'),el('p','','Explore a rotatable 3D aircraft, hear a fictional firefighter guide, work through three short exercises and download a record of your decisions.'),el('h3','','What the instructor does'),el('p','','Prof uses your supplied still portrait with device narration. You can also select the stylised 3D guide, which blinks, moves its head and animates its mouth while the device speaks. Narration and feedback are pre-written. This version is a scripted training demo; it does not use a generative AI model or a photorealistic video avatar.'),el('h3','','What the scene represents'),el('p','','The aircraft and emergency vehicle are illustrative. Smoke, fire and vehicle positions are not a physical simulation, an aircraft rescue chart or tactical guidance. The same scene provides context for all three exercises.'),el('h3','','What is needed for formal training'),el('p','','AM Risk instructors need to validate the curriculum, applicable standards and editions, aircraft-specific information, local emergency procedures and assessment methods. This demo issues no certificate or regulatory approval.'),el('h3','','Simple access'),el('p','','No Hugging Face token, account password or microphone is required to use the demo. Choices and completed attempts are saved in this browser and appear in the academy progress page. Clearing browser data removes them. Export a debrief to keep a separate copy. Narration depends on the browser and device voice service.'));
}$('info-dialog').showModal();
}
$('start-button').addEventListener('click',begin);$('next-button').addEventListener('click',next);$('replay-button').addEventListener('click',()=>speak(narration));
$('mute-button').addEventListener('click',()=>{muted=!muted;stopVoice();$('mute-button').setAttribute('aria-pressed',String(muted));$('mute-button').textContent=muted?'×♫':'♫';$('mute-button').setAttribute('aria-label',muted?'Unmute narration':'Mute narration');$('voice-status').textContent=muted?'NARRATION MUTED · TEXT AVAILABLE':'NARRATION ON · PRESS REPLAY';});
$('restart-button').addEventListener('click',()=>{stopVoice();sessions[current]=fresh();persist();airfield?.setMission(current);airfield?.observe(-1);document.querySelectorAll('[data-observe]').forEach(b=>b.classList.remove('selected'));$('scene-callout').hidden=true;render();});
document.querySelectorAll('[data-observe]').forEach(b=>b.addEventListener('click',()=>observe(Number(b.dataset.observe))));
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>{airfield?.view(b.dataset.view);document.querySelectorAll('[data-view]').forEach(btn=>{btn.classList.toggle('selected',btn===b);btn.setAttribute('aria-pressed',String(btn===b));});}));
function updateMotion(){airfield?.setPaused(paused);instructor?.setPaused(paused||$('guide-style').value==='portrait');$('motion-button').setAttribute('aria-pressed',String(paused));$('motion-button').textContent=paused?'▶ Resume motion':'Ⅱ Pause motion';}
$('motion-button').addEventListener('click',()=>{paused=!paused;updateMotion();});
$('sources-tab').addEventListener('click',()=>showDialog('sources'));$('footer-sources').addEventListener('click',()=>showDialog('sources'));$('notes-tab').addEventListener('click',()=>showDialog('notes'));$('about-button').addEventListener('click',()=>showDialog('about'));$('close-dialog').addEventListener('click',()=>$('info-dialog').close());$('simulator-tab').addEventListener('click',()=>{if($('info-dialog').open)$('info-dialog').close();$('exercise').scrollIntoView({behavior:paused?'auto':'smooth',block:'start'});});
$('info-dialog').addEventListener('click',e=>{if(e.target===$('info-dialog')){const b=e.target.getBoundingClientRect();if(e.clientX<b.left||e.clientX>b.right||e.clientY<b.top||e.clientY>b.bottom)e.target.close();}});
window.addEventListener('pagehide',stopVoice);document.addEventListener('visibilitychange',()=>{if(document.hidden){stopVoice();$('voice-status').textContent='NARRATION PAUSED · PRESS REPLAY';}});
$('guide-style').addEventListener('change',()=>{const photo=$('guide-style').value==='portrait';$('prof-portrait').hidden=!photo;$('avatar').hidden=photo;$('guide-label').textContent=photo?'PROF PORTRAIT':'3D GUIDE';$('guide-name').textContent=photo?'Prof':'Instructor AM';updateMotion();});
render();updateMotion();
// Graphics are optional: the full exercise works if WebGL is unavailable.
import('./scene.js').then(({createAirfield,createInstructor})=>{
try{airfield=createAirfield($('scene'),observe);airfield.setMission(current);}catch(e){$('scene-fallback').hidden=false;console.warn('Aircraft graphics unavailable:',e.message);}
try{instructor=createInstructor($('avatar'));instructor.setPaused(true);}catch(e){$('avatar-fallback').hidden=false;console.warn('Instructor graphics unavailable:',e.message);}
updateMotion();
}).catch(e=>{$('scene-fallback').hidden=false;$('avatar-fallback').hidden=false;console.warn('Graphics could not load:',e.message);});