mathly / static /science.js
3v324v23's picture
feat(science): implement interactive riddle voting system with CSS animations
a4bc651
Raw History Blame Contribute Delete
11 kB
/* 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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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};
})();