File size: 11,020 Bytes
a4bc651 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | /* The science path (app/science.py): the unit ืืืืืช and its riddle skills, solved by the class together.
The host opens a vote and shows its code on the board; students join with the code on their own devices and vote.
Every screen asks the server for the room about once a second and a half. Votes stay hidden until the host checks;
then the rocket is launched with the majority's fuel: it flies, falls back (too little fuel) or stays on the pad
(too heavy). math.js opens this from the subject picker: MathlyScience.show(container, {back}). */
(()=>{
const css=document.createElement("link"); css.rel="stylesheet"; css.href=document.currentScript.src.replace("science.js","science.css");
document.head.append(css);
const esc=s=>String(s??"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
const fmt=s=>esc(s).replace(/\$([^$]+)\$/g,'<span class="m" dir="ltr">$1</span>'); // $โฆ$: math, set left to right
const store={get:(k,s=localStorage)=>{ try{ return s.getItem(k); }catch{ return null; } },
set:(k,v,s=localStorage)=>{ try{ s.setItem(k,v); }catch{} }};
// one vote per device: a random id kept on it
const VOTER=(()=>{ let id=store.get("mathly.voter"); if(!id||id.length<8){ id=(crypto.randomUUID?.()||String(Math.random()).slice(2)+Date.now()).replace(/-/g,""); store.set("mathly.voter",id); } return id; })();
const hostKey=code=>store.get(`mathly.science.host:${code}`,sessionStorage)||"";
const POLL=1500;
let box=null, PATH=null, ROOM=null, SHOWN="", timer=null, launched=new Set(), busy=false, msg="";
async function call(url,options={}){
const code=ROOM?.code;
const r=await fetch(url,{...options,headers:{"Content-Type":"application/json",...(code?{"X-Host":hostKey(code)}:{})}});
const body=await r.json().catch(()=>({}));
if(!r.ok){ const e=new Error(body.detail||`ืืฉืจืช ืขื ื ${r.status}`); e.status=r.status; throw e; }
return body;
}
const SAY={"no vote with this code":"ืืื ืืฆืืขื ืคืชืืื ืขื ืืงืื ืืื. ืืืงื ืืช ืืงืื ืื ืกื ืฉืื.","no votes yet":"ืขืื ืืฃ ืืื ืื ืืฆืืืข.",
"this riddle is no longer open for votes":"ืืืฆืืขื ืขื ืืืืื ืืืืช ืืืจ ื ืกืืจื.","this vote is full":"ืืืฆืืขื ืืืื.",
"too many votes are open right now":"ืืฉ ืขืืฉืื ืืืชืจ ืืื ืืฆืืขืืช ืคืชืืืืช. ื ืกื ืฉืื ืืขืื ืืื ืืงืืช."};
const say=e=>SAY[e.message]||e.message||"ืืฉืื ืืฉืชืืฉ. ื ืกื ืฉืื.";
function stop(){ clearTimeout(timer); timer=null; }
const alive=()=>box&&box.isConnected&&box.querySelector(".sci");
function draw(html){ box.innerHTML=`<div class="sci">${html}</div>`; }
const header=(title,sub,act='<button class="mx-btn" data-act="subjects">ืืื ืืืงืฆืืขืืช</button>')=>
`<header class="mx-top"><div><h1>${esc(title)}</h1><p class="mx-sub">${esc(sub)}</p></div>${act}</header>`;
async function show(container,opts={}){
if(box!==container){ container.addEventListener("click",onClick); }
box=container; stop(); ROOM=null; msg="";
draw(`${header("ืืืขืื","ืืืงืฆืืขืืช ืฉืื")}<p class="mx-note">ืืืขื ืื ืืช ืืืกืืืโฆ</p>`);
if(!PATH){
try{ PATH=await call("/api/science"); }
catch(e){ return draw(`${header("ืืืขืื","ืืืงืฆืืขืืช ืฉืื")}<p class="mx-note">ืื ืืฆืืื ื ืืืขืื ืืช ืืืกืืื: ${esc(say(e))}</p>`); }
}
drawPath();
}
function drawPath(){
stop(); ROOM=null;
draw(`${header(PATH.subject,"ืืืงืฆืืขืืช ืฉืื")}${PATH.units.map(u=>`<section class="sci-unit">
<div class="sci-unit-head"><span aria-hidden="true">๐งฉ</span><div><h2>${esc(u.name)}</h2><p>${esc(u.description||"")}</p></div></div>
${PATH.skills.filter(s=>s.unit===u.id).map(s=>`<button class="sci-skill" data-sci="lobby" data-skill="${esc(s.id)}">
<span class="sci-skill-icon" aria-hidden="true">๐</span><span><b>${esc(s.name)}</b><small>${esc(s.description||"")}</small><small>${s.riddles} ืืืืืช ยท ืืฆืืขื ืืืชืชืืช</small></span><i aria-hidden="true">โน</i></button>`).join("")}
</section>`).join("")}`);
}
function drawLobby(skillId){
stop(); ROOM=null;
const s=PATH.skills.find(x=>x.id===skillId);
draw(`${header(s.name,"ืืืืืช ยท ืืืขืื",'<button class="mx-btn" data-sci="path">ืืืจื</button>')}
<section class="sci-card"><h2>ืคืชืืืช ืืฆืืขื</h2><p>ืืืืจื ืื ืืื ืฉืืฆืื ืขื ืืืื: ืคืืชืืื ืืฆืืขื, ืืืงืืืื ืงืื ืฉืืืืชื ืืฆืืจืคืช ืืืชื.</p>
<button class="mx-btn solid" data-sci="open" data-skill="${esc(s.id)}"${busy?" disabled":""}>ืคืชืืืช ืืฆืืขื ืืืืชื</button></section>
<section class="sci-card"><h2>ืืฆืืจืคืืช ืืืฆืืขื</h2><p>ืืงืืืื ืืช ืืงืื ืฉืืืคืืข ืขื ืืืื.</p>
<form class="sci-join" data-sci-join><input name="code" inputmode="numeric" autocomplete="off" maxlength="4" pattern="[0-9]{4}" placeholder="ืงืื ืื 4 ืกืคืจืืช" aria-label="ืงืื ืืืฆืืขื" required>
<button class="mx-btn solid"${busy?" disabled":""}>ืืฆืืจืคืืช</button></form></section>
${msg?`<p class="sci-msg" role="alert">${esc(msg)}</p>`:""}`);
box.querySelector("[data-sci-join]").onsubmit=e=>{ e.preventDefault(); join(e.target.code.value.trim(),s.id); };
}
async function openRoom(skillId){
busy=true; msg=""; drawLobby(skillId);
try{ const st=await call("/api/science/rooms",{method:"POST",body:JSON.stringify({skill:skillId})});
store.set(`mathly.science.host:${st.code}`,st.host_key,sessionStorage); ROOM={code:st.code,skill:skillId}; busy=false; render(st); poll(); }
catch(e){ busy=false; msg=say(e); drawLobby(skillId); }
}
async function join(code,skillId){
if(!/^\d{4}$/.test(code)){ msg="ืืงืื ืืื 4 ืกืคืจืืช."; return drawLobby(skillId); }
busy=true; msg=""; ROOM={code,skill:skillId};
try{ const st=await call(`/api/science/rooms/${code}?voter=${VOTER}`); busy=false; render(st); poll(); }
catch(e){ busy=false; ROOM=null; msg=say(e); drawLobby(skillId); }
}
function poll(){
stop();
timer=setTimeout(async()=>{
if(!ROOM||!alive()) return stop();
try{ render(await call(`/api/science/rooms/${ROOM.code}?voter=${VOTER}`)); }
catch(e){ if(e.status===404){ msg="ืืืฆืืขื ืืกืชืืืื."; return drawLobby(ROOM.skill); } }
poll();
},POLL);
}
async function act(url,body){
try{ const st=await call(url,{method:"POST",...(body?{body:JSON.stringify(body)}:{})}); ROOM.err=""; render(st,true); }
catch(e){ ROOM.err=say(e); render(ROOM.last,true); }
}
const OUT={flies:["ืืืื ืืืจืื! ๐","flies"],falls:["ืืืืง ื ืืืจ ืืืืฆืข ืืืจื, ืืืืื ื ืคื ืืืืจื","falls"],stuck:["ืืืื ืืื ืืื ืืื ืื ืืื ืืฉืืืืจ","stuck"]};
function rocket(st){
const key=`${st.code}:${st.n}`, now=st.phase==="checked"&&!launched.has(key);
if(now) launched.add(key);
const cls=st.phase!=="checked"?"":`${now?"go":"end"}-${st.outcome}`;
return `<div class="sci-stage ${cls}" aria-hidden="true"><div class="sci-sky"></div>
<svg class="sci-rocket" viewBox="0 0 60 140"><g class="sci-flame"><path d="M20 112 Q30 150 40 112Z" fill="#FF8B5C"/><path d="M25 112 Q30 136 35 112Z" fill="#F7C948"/></g>
<path d="M30 6 C44 22 46 50 46 80 V108 H14 V80 C14 50 16 22 30 6Z" fill="#EAF0FF"/><path d="M30 6 C38 15 42 26 44 36 H16 C18 26 22 15 30 6Z" fill="#F08A96"/>
<circle cx="30" cy="56" r="8" fill="#6FB3D2" stroke="#27335F" stroke-width="3"/><path d="M14 84 L2 110 H14Z M46 84 L58 110 H46Z" fill="#F08A96"/><rect x="18" y="106" width="24" height="6" rx="2" fill="#5B6694"/></svg>
<div class="sci-smoke"><i></i><i></i><i></i></div><div class="sci-pad"></div></div>`;
}
function render(st,force=false){
if(!ROOM||!st) return;
const sig=JSON.stringify(st)+(ROOM.err||"");
if(!force&&sig===SHOWN&&alive()) return;
SHOWN=sig; ROOM.last=st;
const done=st.phase==="checked", total=st.votes, counts=st.counts||[];
const choices=st.choices.map((text,i)=>{
const pct=done&&total?Math.round(100*counts[i]/total):0;
const cls=["sci-choice",st.mine===i&&"mine",done&&i===st.picked&&"picked",done&&i===st.right&&"right",st.tie.includes(i)&&"tied"].filter(Boolean).join(" ");
return `<button class="${cls}" data-sci="vote" data-choice-i="${i}"${done?" disabled":""} aria-pressed="${st.mine===i}">
${done?`<span class="sci-bar" style="width:${pct}%"></span>`:""}<b>โฝ ${esc(text)}</b>${done?`<small>${counts[i]===1?"ืงืื ืืื":`${counts[i]} ืงืืืืช`}</small>`:st.mine===i?"<small>ืืืฆืืขื ืฉืื</small>":""}</button>`;
}).join("");
const tie=st.tie.length&&!done?`<p class="sci-msg">ืชืืงื ืืื ${st.tie.map(i=>esc(st.choices[i])).join(" ืืืื ")}. ืืคืฉืจ ืืฉื ืืช ืืฆืืขื ืืืืืืง ืฉืื.</p>`:"";
const result=done?`<section class="sci-result ${OUT[st.outcome][1]}" role="status"><h3>${OUT[st.outcome][0]}</h3>
<p>ืืจืื ืืืจ <b>${esc(st.choices[st.picked])}</b> (${counts[st.picked]} ืืชืื ${total}).</p><p>${fmt(st.why)}</p>
${st.picked!==st.right?`<p>ืืชืฉืืื ืฉืืืืชื ืืฆืืืื: <b>${esc(st.choices[st.right])}</b></p>`:""}</section>`:"";
const host=st.host?`<div class="sci-host">${done
?(st.n<st.of?'<button class="mx-btn solid" data-sci="next">ืืืืื ืืืื</button>':'<button class="mx-btn solid" data-sci="path">ืกืืื</button>')
:`<button class="mx-btn solid" data-sci="check"${total?"":" disabled"}>ืืืืงื: ืฉืืืืจ ืขื ืชืฉืืืช ืืจืื</button>`}</div>`:"";
draw(`${header(st.name,`ืืืื ${st.n} ืืชืื ${st.of}`,'<button class="mx-btn" data-sci="leave">ืืฆืืื ืืืืฆืืขื</button>')}
<div class="sci-code${st.host?" big":""}"><span>${st.host?"ืืงืื ืืืฆืืจืคืืช":"ืงืื ืืืฆืืขื"}</span><b dir="ltr">${esc(st.code)}</b></div>
<section class="sci-card sci-riddle"><h2>${esc(st.title)}</h2><p>${fmt(st.story)}</p><p class="sci-q">${esc(st.question)}</p></section>
${rocket(st)}${result}
<div class="sci-choices">${choices}</div>
<p class="sci-count" aria-live="polite">${done?`${total} ืืฆืืืขื`:`${total} ${total===1?"ืืฆืืืข":"ืืฆืืืขื"} ืขื ืขืืฉืื ยท ืืชืืฆืืืช ืืืืฉืคื ืืืืืงื`}</p>
${tie}${ROOM.err?`<p class="sci-msg" role="alert">${esc(ROOM.err)}</p>`:""}${host}`);
}
function onClick(e){
const t=e.target.closest("[data-sci]"); if(!t||!box.contains(t)) return;
switch(t.dataset.sci){
case "path": return drawPath();
case "lobby": msg=""; return drawLobby(t.dataset.skill);
case "open": return openRoom(t.dataset.skill);
case "leave": { const s=ROOM?.skill; stop(); ROOM=null; msg=""; return drawLobby(s); }
case "vote": return act(`/api/science/rooms/${ROOM.code}/vote`,{voter:VOTER,choice:+t.dataset.choiceI,n:ROOM.last.n});
case "check": return act(`/api/science/rooms/${ROOM.code}/check?voter=${VOTER}`);
case "next": return act(`/api/science/rooms/${ROOM.code}/next?voter=${VOTER}`);
}
}
window.MathlyScience={show,stop};
})();
|