diff --git a/app.js b/app.js index 90720a7..1a36665 100644 --- a/app.js +++ b/app.js @@ -56,6 +56,7 @@ const CASES = [ explanation:"Типичный ангинозный синдром, подъём ST в передних отведениях и реципрокная депрессия — STEMI. Время = гибнущий миокард, показана экстренная реперфузия." }, emergency:{ + time:60, intro:"Пациент в острейшей стадии. У вас 60 секунд, чтобы оказать экстренную помощь.", actions:[ {t:"Вызвать реанимационную бригаду, активировать STEMI-маршрут (door-to-balloon ≤ 90 мин)", n:true}, @@ -130,6 +131,7 @@ const CASES = [ explanation:"Депрессия ST + подъём тропонина + высокий риск по GRACE = NSTEMI. Тромболизис при NSTEMI НЕ показан — показана ранняя инвазивная стратегия." }, emergency:{ + time:60, intro:"Пациентка с NSTEMI высокого риска. У вас 60 секунд на оказание экстренной помощи.", actions:[ {t:"Двойная антитромбоцитарная терапия + антикоагулянт", n:true}, @@ -203,6 +205,7 @@ const CASES = [ explanation:"Экспираторная одышка, свистящие хрипы, SpO₂ 91%, ПСВ < 50% — острый тяжёлый приступ астмы. Основа: бронходилататоры и системные ГКС." }, emergency:{ + time:60, intro:"Пациентка задыхается. У вас 60 секунд оказать экстренную помощь.", actions:[ {t:"Кислород до SpO₂ 94–98%", n:true}, @@ -275,6 +278,7 @@ const CASES = [ explanation:"ХОБЛ в анамнезе, гнойная мокрота, лихорадка, SpO₂ 86%, гиперкапния по газометрии — обострение ХОБЛ с ОДН 2 типа. Кислород титровать до 88–92%." }, emergency:{ + time:60, intro:"Пациент с гиперкапнической ОДН. У вас 60 секунд оказать экстренную помощь.", actions:[ {t:"Контролируемая О₂-терапия (Venturi 24–28%, цель SpO₂ 88–92%)", n:true}, @@ -348,6 +352,7 @@ const CASES = [ explanation:"Травма, массивная кровопотеря, ЧСС 130, АД 80/40, шоковый индекс 1.6, бледность, олигурия — геморрагический шок III–IV. Приоритет — остановка кровотечения и восстановление ОЦК." }, emergency:{ + time:60, intro:"Пациент в геморрагическом шоке, нестабилен. У вас 60 секунд на оказание экстренной помощи.", actions:[ {t:"Немедленная остановка наружного кровотечения (прямое давление/жгут)", n:true}, @@ -421,6 +426,7 @@ const CASES = [ explanation:"Инфекционный очаг (пневмония), лихорадка, гипотензия после инфузии, лактат 4.2, олигурия, спутанность сознания — септический шок. Выполняйте hour-1 bundle." }, emergency:{ + time:60, intro:"Пациент в септическом шоке. У вас 60 секунд выполнить Sepsis hour-1 bundle.", actions:[ {t:"Измерить лактат", n:true}, @@ -459,7 +465,6 @@ function pickVoice(preferMale){ const femaleRe=/irina|katya|milena|tatiana|anna|maria|female|женский/i; if(preferMale){ const m=ru.find(v=>maleRe.test(v.name)); if(m) return m; } else { const f=ru.find(v=>femaleRe.test(v.name)); if(f) return f; } - // предпочесть нейросетевые/качественные движки const q=ru.find(v=>/google|yandex|microsoft|neural|premium/i.test(v.name)); if(q) return q; return ru[0]; } @@ -476,15 +481,12 @@ function stopSpeak(){ } function splitPhrases(text){ - return text - .split(/(?<=[.!?…])\s+|(?<=…)\s*/) - .map(s=>s.trim()) - .filter(Boolean); + return text.split(/(?<=[.!?…])\s+|(?<=…)\s*/).map(s=>s.trim()).filter(Boolean); } function speakPatient(text, opts, btn, avatar){ const synth=window.speechSynthesis; - if(!synth){ alert("Озвучка не поддерживается этим браузером."); return; } + if(!synth){ return; } if(speakState.playing){ stopSpeak(); return; } stopSpeak(); const phrases=splitPhrases(text); @@ -521,14 +523,188 @@ function playNext(){ window.addEventListener("beforeunload",()=>{ if("speechSynthesis" in window) window.speechSynthesis.cancel(); }); /* ========================================================= - Рендер карточек кейсов + фильтры + Геймификация: профиль, уровни, достижения, рейтинг ========================================================= */ -const grid=document.getElementById("caseGrid"); +const STORE={profile:"medigra_profile_v1", progress:"medigra_progress_v1", results:"medigra_results_v1"}; +const AVATARS=["🧑‍⚕️","👨‍⚕️","👩‍⚕️","🧑‍🎓","👨‍🎓","👩‍🎓","🩺","⚕️"]; + +const LEVELS=[ + {lvl:1,name:"Стажёр",xp:0}, + {lvl:2,name:"Студент",xp:500}, + {lvl:3,name:"Интерн",xp:1500}, + {lvl:4,name:"Ординатор",xp:3000}, + {lvl:5,name:"Врач",xp:5200}, + {lvl:6,name:"Специалист",xp:8000}, + {lvl:7,name:"Эксперт",xp:12000}, + {lvl:8,name:"Профессор",xp:18000} +]; + +const ACHIEVEMENTS=[ + {id:"first", name:"Первый шаг", desc:"Решить первый кейс", icon:"👣", check:s=>s.completedCases>=1}, + {id:"dx100", name:"Точный диагноз", desc:"100% за шаг диагноза", icon:"🎯", check:s=>s.perfectDxCount>=1}, + {id:"perfect", name:"Безупречный кейс", desc:"100% за весь кейс", icon:"💎", check:s=>s.perfectCases>=1}, + {id:"noerr", name:"Чистая работа", desc:"Кейс без лишних назначений", icon:"✨", check:s=>s.noErrorCases>=1}, + {id:"fast", name:"Молниеносная реакция", desc:"Завершить помощь с запасом > 30 сек", icon:"⚡", check:s=>s.fastCases>=1}, + {id:"aks", name:"Мастер ОКС", desc:"Пройти все кейсы темы ОКС", icon:"❤️", check:s=>s.themeDone.aks}, + {id:"odn", name:"Мастер дыхания", desc:"Пройти все кейсы темы ОДН", icon:"🫁", check:s=>s.themeDone.odn}, + {id:"shock", name:"Мастер шока", desc:"Пройти все кейсы темы шок", icon:"🩸", check:s=>s.themeDone.shock}, + {id:"alldx", name:"Великий диагност", desc:"Все диагнозы верны", icon:"🧠", check:s=>s.allDxCases>=6}, + {id:"all", name:"Полное прохождение", desc:"Пройти все 6 кейсов", icon:"🗺️", check:s=>s.completedCases>=6}, + {id:"prof", name:"Профессор", desc:"Достичь максимального уровня", icon:"👑", check:s=>s.maxLevel} +]; + +function load(key, def){ try{ const v=JSON.parse(localStorage.getItem(key)); return v||def; }catch(e){ return def; } } +function save(key, val){ try{ localStorage.setItem(key, JSON.stringify(val)); }catch(e){} } + +let profile=load(STORE.profile, {name:"", avatar:"🧑‍⚕️", xp:0, achievements:[]}); +let progress=load(STORE.progress, {}); +let results=load(STORE.results, []); +function saveAll(){ save(STORE.profile,profile); save(STORE.progress,progress); save(STORE.results,results); } + +function levelInfo(xp){ + let cur=LEVELS[0], idx=0; + for(let i=0;i=LEVELS[i].xp){ cur=LEVELS[i]; idx=i; } } + const next=LEVELS[idx+1]; + const into=xp-cur.xp; + const span=next?(next.xp-cur.xp):1; + const pct=next?Math.min(100,Math.round(into/span*100)):100; + return {cur, next, idx, into, span, pct, isMax:!next}; +} + +function starsFor(score){ return score>=85?3:score>=70?2:score>=55?1:0; } + +function computeStats(){ + let completed=0, perfectCases=0, noErrorCases=0, fastCases=0, perfectDxCount=0, allDx=0; + const themeDone={aks:false,odn:false,shock:false}; + CASES.forEach(c=>{ + const p=progress[c.id]; + if(p&&p.completed){ + completed++; + if(p.stars>=3) perfectCases++; + if(p.noErrors) noErrorCases++; + if(p.fast) fastCases++; + if(p.perfectDx) perfectDxCount++; + if(p.dxCorrect) allDx++; + } + }); + ["aks","odn","shock"].forEach(t=>{ + const arr=CASES.filter(c=>c.theme===t); + themeDone[t]=arr.every(c=>progress[c.id]&&progress[c.id].completed); + }); + return {completedCases:completed, perfectCases, noErrorCases, fastCases, perfectDxCount, allDxCases:allDx, themeDone, maxLevel:levelInfo(profile.xp).isMax}; +} + +function countFP(items,picked){ let n=0; items.forEach((it,i)=>{ if(!it.n && picked.has(i)) n++; }); return n; } + +function checkAchievements(){ + const stats=computeStats(); + const newly=[]; + ACHIEVEMENTS.forEach(a=>{ + if(!profile.achievements.includes(a.id) && a.check(stats)){ + profile.achievements.push(a.id); + newly.push(a); + } + }); + if(newly.length) save(STORE.profile,profile); + return newly; +} + +function awardXp(c, score){ + const prev=progress[c.id]||{bestScore:0}; + const delta=Math.max(0, Math.round((score-(prev.bestScore||0))*3)); + if(delta>0){ profile.xp+=delta; save(STORE.profile,profile); } + return delta; +} + +function recordCase(c, score, meta){ + const prev=progress[c.id]||{completed:false,bestScore:0,stars:0}; + progress[c.id]={ + completed:true, + bestScore:Math.max(prev.bestScore||0,score), + stars:Math.max(prev.stars||0,starsFor(score)), + dxCorrect: prev.dxCorrect||meta.dxCorrect, + perfectDx: prev.perfectDx||meta.perfectDx, + noErrors: prev.noErrors||meta.noErrors, + fast: prev.fast||meta.fast, + played:(prev.played||0)+1 + }; + save(STORE.progress,progress); +} + +/* ---- тосты ---- */ +function toast(kind, title, sub){ + const wrap=document.getElementById("toasts"); + const el=document.createElement("div"); + el.className="toast "+kind; + const ico = kind==="lvl"?"⬆️": kind==="achv"?"🏅":"✨"; + el.innerHTML=`
${ico}
${title}${sub}
`; + wrap.appendChild(el); + setTimeout(()=>{ el.style.transition="opacity .4s, transform .4s"; el.style.opacity="0"; el.style.transform="translateX(120%)"; setTimeout(()=>el.remove(),420); }, 4200); +} + +/* ========================================================= + Рендер интерфейса + ========================================================= */ +const $=id=>document.getElementById(id); + +function renderPlayerBar(){ + const li=levelInfo(profile.xp); + const bar=$("playerBar"); + bar.innerHTML=` +
${profile.avatar}
+
+
${profile.name||"Гость"} · ${li.cur.name}
+
Уровень ${li.cur.lvl}${li.next?` · до ${li.next.name} ${li.next.xp-profile.xp} XP`:" · максимум"}
+
+
`; +} + +function renderHeroStats(){ + const stats=computeStats(); + const li=levelInfo(profile.xp); + $("heroStats").innerHTML=` +
${li.cur.lvl}уровень · ${li.cur.name}
+
${profile.xp}опыт (XP)
+
${stats.completedCases}/6кейсов пройдено
+
${profile.achievements.length}/${ACHIEVEMENTS.length}достижений
`; +} + +function renderThemes(){ + const wrap=$("themes"); + const themes=[ + {key:"aks", ico:"❤️", name:"ОКС и инфаркт", desc:"STEMI, NSTEMI — диагностика и реперфузия.", cls:"t-aks"}, + {key:"odn", ico:"🫁", name:"ОДН · астма · ХОБЛ", desc:"Астматический статус, обострение ХОБЛ.", cls:"t-odn"}, + {key:"shock", ico:"🩸", name:"Шок и травма", desc:"Геморрагический и септический шок.", cls:"t-shock"} + ]; + wrap.innerHTML=themes.map(t=>{ + const arr=CASES.filter(c=>c.theme===t.key); + const done=arr.filter(c=>progress[c.id]&&progress[c.id].completed).length; + const pct=Math.round(done/arr.length*100); + const stars=arr.reduce((a,c)=>a+((progress[c.id]&&progress[c.id].stars)||0),0); + const maxStars=arr.length*3; + return `
+
${t.ico}

${t.name}

+

${t.desc}

+
+
${done}/${arr.length} кейсов${"★".repeat(stars)}${"☆".repeat(maxStars-stars)}
+
`; + }).join(""); + wrap.querySelectorAll(".theme").forEach(el=>el.addEventListener("click",()=>{ + setFilter(el.dataset.theme); + document.getElementById("cases").scrollIntoView({behavior:"smooth"}); + })); +} + function sevDots(n){let s="";for(let i=0;i<3;i++)s+=``;return s;} function renderCards(filter="all"){ + const grid=$("caseGrid"); grid.innerHTML=""; CASES.filter(c=>filter==="all"||c.theme===filter).forEach(c=>{ + const p=progress[c.id]; + const done=p&&p.completed; + const stars=done?p.stars:0; + const starsHTML=[0,1,2].map(i=>i★`).join(""); const el=document.createElement("article"); el.className=`case-card t-${c.theme}`; el.innerHTML=` @@ -539,32 +715,92 @@ function renderCards(filter="all"){
${c.patient.name}, ${c.patient.age.split(",")[0]}
-

Пациент сам расскажет жалобы голосом. Назначьте обследования и лечение, поставьте диагноз, окажите экстренную помощь на время.

+

Говорящий пациент. Назначьте обследования и лечение, поставьте диагноз, окажите экстренную помощь на время.

+
${starsHTML}
+
${done?`Лучший результат: ${p.bestScore}% · сыграно ${p.played||1}`:"Ещё не пройден"}
- ${sevDots(c.severity)} - + ${done?'✓ Пройден':''} +
`; el.querySelector(".case-btn").addEventListener("click",e=>{e.stopPropagation(); startExam("single",c.id);}); grid.appendChild(el); }); } -document.getElementById("filters").addEventListener("click",e=>{ - const b=e.target.closest(".filter"); if(!b) return; - document.querySelectorAll(".filter").forEach(f=>f.classList.remove("is-active")); - b.classList.add("is-active"); - renderCards(b.dataset.filter); +function setFilter(filter){ + document.querySelectorAll(".filter").forEach(f=>f.classList.toggle("is-active", f.dataset.filter===filter)); + renderCards(filter); +} + +function renderAchv(){ + const stats=computeStats(); + const grid=$("achvGrid"); + grid.innerHTML=ACHIEVEMENTS.map(a=>{ + const unlocked=profile.achievements.includes(a.id); + return `
+ ${unlocked?"":'🔒'} +
${a.icon}
+

${a.name}

+

${a.desc}

+
`; + }).join(""); + $("achvCount").textContent=`Открыто ${profile.achievements.length} из ${ACHIEVEMENTS.length}.`; +} + +function renderBoard(){ + const wrap=$("boardList"); + const sorted=[...results].sort((a,b)=>b.score-a.score).slice(0,10); + if(!sorted.length){ wrap.innerHTML=`
Пока нет результатов. Пройдите полный экзамен, чтобы попасть в рейтинг.
`; return; } + wrap.innerHTML=sorted.map((r,i)=>{ + const me = r.name===profile.name && r.avatar===profile.avatar; + return `
+
${i+1}
+
${r.avatar}
+
${r.name}${r.date}
+
${r.score}%
+
`; + }).join(""); +} + +function refreshAll(){ + renderPlayerBar(); renderHeroStats(); renderThemes(); renderCards(currentFilter); renderAchv(); renderBoard(); +} +let currentFilter="all"; + +/* ========================================================= + Профиль (модал) + ========================================================= */ +let profOnDone=null; +function openProfile(onDone){ + profOnDone=onDone||null; + $("profName").value=profile.name||""; + const av=$("profAvatars"); + av.innerHTML=AVATARS.map(a=>`
${a}
`).join(""); + av.querySelectorAll(".pm-av").forEach(e=>e.addEventListener("click",()=>{ + av.querySelectorAll(".pm-av").forEach(x=>x.classList.remove("sel")); + e.classList.add("sel"); + })); + $("modalProfile").hidden=false; +} +function closeProfile(){ $("modalProfile").hidden=true; if(profOnDone){ const cb=profOnDone; profOnDone=null; cb(); } } + +$("profSave").addEventListener("click",()=>{ + profile.name=($("profName").value||"").trim().slice(0,20)||"Гость"; + const sel=document.querySelector(".pm-av.sel"); + if(sel) profile.avatar=sel.dataset.a; + save(STORE.profile,profile); + renderPlayerBar(); renderHeroStats(); renderBoard(); + closeProfile(); }); +$("modalProfile").addEventListener("click",e=>{ if(e.target.hasAttribute("data-close-p")){ $("modalProfile").hidden=true; } }); +$("btnProfile").addEventListener("click",()=>openProfile()); +$("btnAchv").addEventListener("click",()=>document.getElementById("achv").scrollIntoView({behavior:"smooth"})); +$("btnBoard").addEventListener("click",()=>document.getElementById("board").scrollIntoView({behavior:"smooth"})); /* ========================================================= Экзамен (полноэкранный wizard) ========================================================= */ -const exam=document.getElementById("exam"); -const examBody=document.getElementById("examBody"); -const examProgressLabel=document.getElementById("examProgressLabel"); -const examDots=document.getElementById("examDots"); -const stageTrack=document.getElementById("stageTrack"); - +const exam=$("exam"), examBody=$("examBody"); const STAGES=[ {key:"patient", label:"Пациент"}, {key:"investigations", label:"Обследование"}, @@ -573,95 +809,74 @@ const STAGES=[ {key:"emergency", label:"Помощь ⏱"}, {key:"result", label:"Результат"} ]; -const STEP_TAGS=["Пациент","Шаг 1","Шаг 2","Шаг 3","Шаг 4","Результат"]; -let state=null; -let timerHandle=null; +let state=null, timerHandle=null; function startExam(mode, caseId){ const queue = mode==="full" ? CASES.map(c=>c.id) : [caseId]; - state={ mode, queue, qIdx:0, stage:0, answers:{}, caseScores:[], emergencyAuto:false }; - exam.hidden=false; - document.body.style.overflow="hidden"; + state={mode, queue, qIdx:0, stage:0, answers:{}, caseScores:[], emergencyLeft:0, emergencyManual:false}; + exam.hidden=false; document.body.style.overflow="hidden"; loadCase(); } - function closeExam(){ - stopSpeak(); - if(timerHandle){ clearInterval(timerHandle); timerHandle=null; } - exam.hidden=true; - document.body.style.overflow=""; + stopSpeak(); stopTimer(); + exam.hidden=true; document.body.style.overflow=""; + refreshAll(); state=null; } - function curCase(){ return CASES.find(c=>c.id===state.queue[state.qIdx]); } function loadCase(){ stopTimer(); state.stage=0; - state.answers={ - investigations:new Set(), - medications:new Set(), - diagnosis:null, - emergency:new Set(), - submitted:{i:false,m:false,d:false,e:false} - }; - state.emergencyAuto=false; + state.answers={investigations:new Set(), medications:new Set(), diagnosis:null, emergency:new Set(), submitted:{i:false,m:false,d:false,e:false}}; + state.emergencyLeft=0; state.emergencyManual=false; stopSpeak(); - renderExamBar(); - renderStageTrack(); - renderPatient(); + renderExamBar(); renderStageTrack(); renderPatient(); } function renderExamBar(){ - examProgressLabel.textContent=`Кейс ${state.qIdx+1} из ${state.queue.length}`; - examDots.innerHTML=state.queue.map((_,i)=>{ + $("examProgressLabel").textContent=`Кейс ${state.qIdx+1} из ${state.queue.length}`; + $("examDots").innerHTML=state.queue.map((_,i)=>{ const done=i`; }).join(""); } - function renderStageTrack(){ - stageTrack.innerHTML=STAGES.map((s,i)=>{ + $("stageTrack").innerHTML=STAGES.map((s,i)=>{ let cls=""; if(i${mark}${s.label}${i→':""}`; }).join(""); } -/* ---- SVG кольцо ---- */ function ring(pct, size, stroke, color, track, num){ const r=(size-stroke)/2, c=2*Math.PI*r, off=c*(1-Math.max(0,Math.min(100,pct))/100); return `
- - -
${num}
`; + +
${num}
`; } -/* ---- Блок пациента ---- */ function patientBlock(c){ - return ` -
-
-
${c.patient.avatar}
-
-

${c.title}

-
${c.patient.name} · ${c.patient.age} · ${c.patient.ctx}
-
${c.themeLabel}
-
+ return `
+
+
${c.patient.avatar}
+
+

${c.title}

+
${c.patient.name} · ${c.patient.age} · ${c.patient.ctx}
+
${c.themeLabel}
-
- 🔊 - Жалобы пациентаВключите звук и нажмите — пациент говорит голосом - -
-
«${c.complaint}»
-
`; +
+
+ 🔊 + Жалобы пациентаВключите звук и нажмите — пациент говорит голосом + +
+
«${c.complaint}»
+
`; } - function vitalsHTML(v){ let extra=""; if(v.temp) extra=`
${v.temp}Температура
`; @@ -670,47 +885,35 @@ function vitalsHTML(v){
${v.bp}АД
${v.rr}ЧДД
${v.spo2}SpO₂
- ${extra} -
`; + ${extra}`; } - function bindSpeak(c, root){ - const btn=root.querySelector("#speakBtn"); - const av=root.querySelector("#pAvatar"); + const btn=root.querySelector("#speakBtn"), av=root.querySelector("#pAvatar"); if(btn) btn.addEventListener("click",()=>speakPatient(c.complaint, c.patient.voice, btn, av)); } -/* ---- Шаг 0: Пациент ---- */ function renderPatient(){ const c=curCase(); - examBody.innerHTML=` -
- ${patientBlock(c)} -
- 📋 Анамнез -
    ${c.anamnesis.map(a=>`
  • ${a}
  • `).join("")}
-
-
- 🩺 Объективный осмотр -
- ${vitalsHTML(c.objective.vitals)} -

Общий осмотр

${c.objective.exam.general}

-

Сердечно-сосудистая система

${c.objective.exam.heart}

-

Дыхательная система

${c.objective.exam.lungs}

-

Прочее

${c.objective.exam.other}

-
-
-
- Знакомство с пациентом - -
-
`; + examBody.innerHTML=`
+ ${patientBlock(c)} +
📋 Анамнез
    ${c.anamnesis.map(a=>`
  • ${a}
  • `).join("")}
+
🩺 Объективный осмотр
+ ${vitalsHTML(c.objective.vitals)} +

Общий осмотр

${c.objective.exam.general}

+

Сердечно-сосудистая система

${c.objective.exam.heart}

+

Дыхательная система

${c.objective.exam.lungs}

+

Прочее

${c.objective.exam.other}

+
+
+ Знакомство с пациентом + +
+
`; bindSpeak(c, examBody); - examBody.querySelector("#goNext").onclick=()=>{ state.stage=1; renderStageTrack(); renderMulti("investigations"); }; + $("goNext").onclick=()=>{ state.stage=1; renderStageTrack(); renderMulti("investigations"); }; examBody.scrollTop=0; } -/* ---- Универсальный шаг множественного выбора ---- */ const MULTI_CFG={ investigations:{tag:"Шаг 1 из 4", title:"Назначьте обследования", sub:"Выберите необходимые исследования. Не назначайте лишнее — за ошибки снижается балл.", btn:"Подтвердить назначения"}, medications:{tag:"Шаг 2 из 4", title:"Назначьте лечение", sub:"Выберите препараты из перечня. Избегайте противопоказанных и неэффективных.", btn:"Подтвердить лечение"}, @@ -721,36 +924,33 @@ function renderMulti(kind){ const c=curCase(); const cfg=MULTI_CFG[kind]; const items = kind==="investigations"?c.investigations : kind==="medications"?c.medications : c.emergency.actions; - const ans = state.answers[kind]; + const ans=state.answers[kind]; const submitted = kind==="investigations"?state.answers.submitted.i : kind==="medications"?state.answers.submitted.m : state.answers.submitted.e; - const isRadio = false; - let emergencyTop=""; if(kind==="emergency" && !submitted) emergencyTop=`
`; - examBody.innerHTML=` -
-
- ${cfg.tag} -

${cfg.title}

-

${kind==="emergency"?c.emergency.intro:cfg.sub}

-
- ${emergencyTop} -
- ${items.map((it,i)=>{ - const checked=ans.has(i)?"checked":""; - return ``; - }).join("")} -
-
- ${submitted?`Балл за шаг: `:"Выберите варианты"} - -
-
`; + examBody.innerHTML=`
+
+ ${cfg.tag} +

${cfg.title}

+

${kind==="emergency"?c.emergency.intro:cfg.sub}

+
+ ${emergencyTop} +
+ ${items.map((it,i)=>{ + const checked=ans.has(i)?"checked":""; + return ``; + }).join("")} +
+
+ ${submitted?`Балл за шаг: `:"Выберите варианты"} + +
+
`; const optList=examBody.querySelector("#optList"); optList.addEventListener("click",e=>{ @@ -773,62 +973,51 @@ function renderMulti(kind){ actBtn.onclick=()=>{ if(kind==="investigations") state.answers.submitted.i=true; else if(kind==="medications") state.answers.submitted.m=true; - else { state.answers.submitted.e=true; stopTimer(); } + else { state.answers.submitted.e=true; state.emergencyManual=true; stopTimer(); } renderMulti(kind); - if(kind==="emergency"){} // таймер уже не нужен }; } - - if(kind==="emergency" && !submitted){ startTimer(c); } - if(kind==="emergency" && submitted){ /* таймер снят */ } + if(kind==="emergency" && !submitted) startTimer(c); examBody.scrollTop=0; } -/* ---- Подсветка результата множественного выбора ---- */ function highlightMulti(kind){ const c=curCase(); const items = kind==="investigations"?c.investigations : kind==="medications"?c.medications : c.emergency.actions; const ans=state.answers[kind]; - const labels=examBody.querySelectorAll(".opt"); - labels.forEach(lbl=>{ - const i=+lbl.dataset.i; const it=items[i]; const got=ans.has(i); - if(it.n && got){ lbl.classList.add("correct"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`верно`); } - else if(!it.n && got){ lbl.classList.add("wrong-pick"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`лишнее`); } - else if(it.n && !got){ lbl.classList.add("missed"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`пропущено`); } + examBody.querySelectorAll(".opt").forEach(lbl=>{ + const i=+lbl.dataset.i, it=items[i], got=ans.has(i); + if(it.n&&got){ lbl.classList.add("correct"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`верно`); } + else if(!it.n&&got){ lbl.classList.add("wrong-pick"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`лишнее`); } + else if(it.n&&!got){ lbl.classList.add("missed"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`пропущено`); } }); - const sc=multiScore(items,ans); - const ss=examBody.querySelector("#stepScore"); - if(ss) ss.textContent=sc+"%"; + const ss=examBody.querySelector("#stepScore"); if(ss) ss.textContent=multiScore(items,ans)+"%"; } -/* ---- Шаг 3: Диагноз ---- */ function renderDiagnosis(){ const c=curCase(); const submitted=state.answers.submitted.d; - const ans=state.answers.diagnosis; - examBody.innerHTML=` -
-
- Шаг 3 из 4 -

Поставьте диагноз

-

Выберите наиболее вероятный диагноз из вариантов.

-
-
- ${c.diagnosis.options.map((o,i)=>{ - const checked=ans===i?"checked":""; - return ``; - }).join("")} -
-
- ${submitted?`Балл за шаг: `:"Выберите один вариант"} - -
-
`; - + examBody.innerHTML=`
+
+ Шаг 3 из 4 +

Поставьте диагноз

+

Выберите наиболее вероятный диагноз из вариантов.

+
+
+ ${c.diagnosis.options.map((o,i)=>{ + const checked=state.answers.diagnosis===i?"checked":""; + return ``; + }).join("")} +
+
+ ${submitted?`Балл за шаг: `:"Выберите один вариант"} + +
+
`; const optList=examBody.querySelector("#optList"); optList.addEventListener("click",e=>{ if(submitted) return; @@ -836,7 +1025,6 @@ function renderDiagnosis(){ state.answers.diagnosis=+lbl.dataset.i; optList.querySelectorAll("input").forEach((inp,ii)=>inp.checked=ii===state.answers.diagnosis); }); - const actBtn=examBody.querySelector("#actBtn"); if(submitted){ highlightDiagnosis(); @@ -846,52 +1034,34 @@ function renderDiagnosis(){ } examBody.scrollTop=0; } - function highlightDiagnosis(){ const c=curCase(); - const labels=examBody.querySelectorAll(".opt"); - labels.forEach(lbl=>{ - const i=+lbl.dataset.i; const o=c.diagnosis.options[i]; + examBody.querySelectorAll(".opt").forEach(lbl=>{ + const i=+lbl.dataset.i, o=c.diagnosis.options[i]; if(o.c){ lbl.classList.add("correct"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`верный диагноз`); } else if(i===state.answers.diagnosis){ lbl.classList.add("wrong-pick"); lbl.querySelector(".opt-text").insertAdjacentHTML("beforeend",`неверно`); } }); - const sc = state.answers.diagnosis!==null && c.diagnosis.options[state.answers.diagnosis].c ? 100 : 0; + const sc=state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c?100:0; const ss=examBody.querySelector("#stepScore"); if(ss) ss.textContent=sc+"%"; } - function flashChip(){ - const chip=examBody.querySelector("#stepChip"); - if(!chip) return; - chip.style.background="var(--red-50)"; chip.style.color="var(--red)"; + const chip=examBody.querySelector("#stepChip"); if(!chip) return; + chip.style.background="var(--red-soft)"; chip.style.color="var(--red)"; setTimeout(()=>{ chip.style.background=""; chip.style.color=""; },700); } -/* ---- Таймер экстренной помощи ---- */ function startTimer(c){ - const total=c.emergency.time||60; - let left=total; - const tw=examBody.querySelector("#timerWrap"); - if(!tw) return; + const total=c.emergency.time||60; let left=total; + const tw=examBody.querySelector("#timerWrap"); if(!tw) return; function tick(){ + state.emergencyLeft=left; const pct=left/total*100; - const color = left<=10 ? "var(--red)" : "var(--cyan)"; - const danger = left<=10 ? ' осталось мало времени!' : ""; - tw.innerHTML=` - ${ring(pct,74,7,color,"rgba(255,255,255,.15)",left)} -
-

⏱ Экстренная помощь

-

Отмечайте действия. Время пошло.${danger}

-
-
`; - if(left<=0){ - stopTimer(); - if(!state.answers.submitted.e){ - state.answers.submitted.e=true; - state.emergencyAuto=true; - renderMulti("emergency"); - } - return; - } + const color=left<=10?"var(--red)":"var(--gold)"; + const danger=left<=10?' осталось мало времени!':""; + tw.innerHTML=`${ring(pct,72,7,color,"rgba(255,255,255,.15)",left)} +

⏱ Экстренная помощь

Отмечайте действия. Время пошло.${danger}

+
`; + if(left<=0){ stopTimer(); if(!state.answers.submitted.e){ state.answers.submitted.e=true; state.emergencyManual=false; renderMulti("emergency"); } return; } left--; } tick(); @@ -899,7 +1069,6 @@ function startTimer(c){ } function stopTimer(){ if(timerHandle){ clearInterval(timerHandle); timerHandle=null; } } -/* ---- Подсчёт баллов ---- */ function multiScore(items,picked){ let tp=0,tn=0,fp=0,fn=0; items.forEach((it,i)=>{ const need=it.n, got=picked.has(i); @@ -910,64 +1079,81 @@ function computeCaseScore(c){ const s={ investigations:multiScore(c.investigations,state.answers.investigations), medications:multiScore(c.medications,state.answers.medications), - diagnosis:(state.answers.diagnosis!==null && c.diagnosis.options[state.answers.diagnosis].c)?100:0, + diagnosis:(state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c)?100:0, emergency:multiScore(c.emergency.actions,state.answers.emergency) }; s.total=Math.round(s.investigations*0.2+s.medications*0.2+s.diagnosis*0.3+s.emergency*0.3); return s; } -/* ---- Результат кейса ---- */ +function breakRow(name,val,color){ + return `
${name}
+
+
${val}%
`; +} + function renderCaseResult(){ const c=curCase(); const sc=computeCaseScore(c); - state.caseScores.push({id:c.id, title:c.title, theme:c.theme, themeLabel:c.themeLabel, badge:c.badge, score:sc.total, breakdown:sc}); - const grade=gradeFor(sc.total); - const col=colorFor(sc.total); - examBody.innerHTML=` -
-
-
-
${ring(sc.total,150,12,col,"var(--gray-100)",`${sc.total}%оценка за кейс`)}
-

${grade.title}

-
${grade.text}
-
-
- ${breakRow("Обследования",sc.investigations,"var(--blue)")} - ${breakRow("Лечение",sc.medications,"var(--purple)")} - ${breakRow("Диагноз",sc.diagnosis,"var(--cyan)")} - ${breakRow("Экстренная помощь",sc.emergency,"var(--red)")} -
-
-

📝 Правильный диагноз

-
${c.diagnosis.options.find(o=>o.c).t}${c.diagnosis.explanation}
-

📋 Протокол МЗ РК

-
${c.protocol}${c.protocolNote}
-

🔬 Ключевые данные

-
${c.extra}
-

💡 Ключевые моменты и типичные ошибки

-
${c.keyPoints.map(p=>`
${p.t==="good"?"✅":"⚠️"}${p.x}
`).join("")}
-
+ const meta={ + dxCorrect:state.answers.diagnosis!==null&&c.diagnosis.options[state.answers.diagnosis].c, + perfectDx:sc.diagnosis===100, + noErrors:countFP(c.investigations,state.answers.investigations)===0&&countFP(c.medications,state.answers.medications)===0&&countFP(c.emergency.actions,state.answers.emergency)===0, + fast:state.emergencyManual&&state.emergencyLeft>30 + }; + // награды + const prevLevel=levelInfo(profile.xp).idx; + const xpGain=awardXp(c, sc.total); + recordCase(c, sc.total, meta); + const newAchv=checkAchievements(); + const newLevel=levelInfo(profile.xp).idx; + const stars=starsFor(sc.total); + state.caseScores.push({id:c.id,title:c.title,theme:c.theme,themeLabel:c.themeLabel,badge:c.badge,score:sc.total}); + if(newLevel>prevLevel){ const li=levelInfo(profile.xp); toast("lvl","Новый уровень!",`${li.cur.name} — уровень ${li.cur.lvl}`); } + newAchv.forEach(a=>toast("achv","Достижение!",a.name)); + + const grade=gradeFor(sc.total), col=colorFor(sc.total); + const starsHTML=[0,1,2].map(i=>i★`).join(""); + examBody.innerHTML=`
+
+
+
${ring(sc.total,146,12,col,"var(--gray-100)",`${sc.total}%оценка за кейс`)}
+

${grade.title}

+
${grade.text}
-
- Кейс ${state.qIdx+1} из ${state.queue.length} · ${sc.total}% - +
+
Звёзды
${starsHTML}
+
Опыт
+${xpGain} XP
+ ${newAchv.length?`
🏅
Награды
${newAchv.map(a=>a.icon).join(" ")}
`:""}
-
`; - examBody.querySelector("#actBtn").onclick=nextAfterCase; +
+ ${breakRow("Обследования",sc.investigations,"var(--burgundy)")} + ${breakRow("Лечение",sc.medications,"var(--teal)")} + ${breakRow("Диагноз",sc.diagnosis,"var(--gold)")} + ${breakRow("Экстренная помощь",sc.emergency,"var(--rust)")} +
+
+

📝 Правильный диагноз

+
${c.diagnosis.options.find(o=>o.c).t}${c.diagnosis.explanation}
+

📋 Протокол МЗ РК

+
${c.protocol}${c.protocolNote}
+

🔬 Ключевые данные

+
${c.extra}
+

💡 Ключевые моменты и типичные ошибки

+
${c.keyPoints.map(p=>`
${p.t==="good"?"✅":"⚠️"}${p.x}
`).join("")}
+
+
+
+ Кейс ${state.qIdx+1} из ${state.queue.length} · ${sc.total}% + +
+
`; + $("actBtn").onclick=nextAfterCase; examBody.scrollTop=0; } -function breakRow(name,val,color){ - return `
-
${name}
-
-
${val}%
-
`; -} - function nextLabel(){ - if(state.mode==="single") return "Завершить"; + if(state.mode==="single") return "Готово"; if(state.qIdx=55) return {title:"Удовлетворительно",text:"Базовые навыки есть, но много ошибок — повторите тему."}; return {title:"Неудовлетворительно",text:"Алгоритмы требуют серьёзной проработки."}; } -function colorFor(pct){ - if(pct>=85) return "var(--green)"; - if(pct>=70) return "var(--cyan)"; - if(pct>=55) return "var(--amber)"; - return "var(--red)"; -} +function colorFor(pct){ return pct>=85?"var(--green)":pct>=70?"var(--teal)":pct>=55?"var(--amber)":"var(--red)"; } function gradeClass(pct){ return pct>=85?"g5":pct>=70?"g4":pct>=55?"g3":"g2"; } -/* ---- Итоговый экран экзамена ---- */ function renderFinal(){ - stopTimer(); + stopTimer(); stopSpeak(); const scores=state.caseScores; const avg=Math.round(scores.reduce((a,s)=>a+s.score,0)/scores.length); - const col=colorFor(avg); - const grade=gradeFor(avg); - // по темам + const col=colorFor(avg), grade=gradeFor(avg); + // запись в рейтинг + results.push({name:profile.name||"Гость", avatar:profile.avatar, score:avg, date:new Date().toLocaleDateString("ru-RU")}); + if(results.length>50) results=results.slice(results.length-50); + save(STORE.results,results); const themes={}; scores.forEach(s=>{ (themes[s.theme]=themes[s.theme]||[]).push(s.score); }); const themeRows=Object.entries(themes).map(([k,arr])=>{ const t=CASES.find(c=>c.theme===k); - return `
${Math.round(arr.reduce((a,b)=>a+b,0)/arr.length)}%${t.themeLabel}
`; + const col=k==="aks"?"var(--burgundy)":k==="odn"?"var(--teal)":"var(--rust)"; + return `
${Math.round(arr.reduce((a,b)=>a+b,0)/arr.length)}%${t.themeLabel}
`; }).join(""); - examBody.innerHTML=` -
-
-
${ring(avg,180,14,col,"var(--gray-100)",`${avg}%`)}
-

Итог экзамена

-
${grade.title}
-

${grade.text} Вы прошли ${scores.length} из ${state.queue.length} кейсов.

-
${themeRows}
-
- ${scores.map((s,i)=>`
- ${i+1} - ${s.title} - ${s.score}% -
`).join("")} -
-
- - -
+ examBody.innerHTML=`
+
+
${ring(avg,174,14,col,"var(--gray-100)",`${avg}%`)}
+

Итог экзамена

+
${grade.title}
+

${grade.text} Вы прошли ${scores.length} кейсов. Результат добавлен в рейтинг.

+
${themeRows}
+
+ ${scores.map((s,i)=>`
+ ${i+1} + ${s.title} + ${s.score}% +
`).join("")}
-
`; - examBody.querySelector("#again").onclick=()=>{ startExam("full"); }; - examBody.querySelector("#closeFin").onclick=closeExam; +
+ + +
+
+
`; + $("again").onclick=()=>{ startExam("full"); }; + $("closeFin").onclick=closeExam; examBody.scrollTop=0; } -/* ---- Кнопки запуска экзамена ---- */ -document.getElementById("startFullExam").addEventListener("click",()=>startExam("full")); -document.getElementById("startFullExam2").addEventListener("click",()=>startExam("full")); -document.getElementById("examExit").addEventListener("click",()=>{ - if(confirm("Выйти из экзамена? Текущий прогресс будет потерян.")) closeExam(); -}); -document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !exam.hidden){ if(confirm("Выйти из экзамена?")) closeExam(); } }); +/* ---- обработчики ---- */ +$("filters").addEventListener("click",e=>{ const b=e.target.closest(".filter"); if(!b) return; setFilter(b.dataset.filter); }); +$("heroStart").addEventListener("click",()=>document.getElementById("map").scrollIntoView({behavior:"smooth"})); +$("heroFull").addEventListener("click",()=>startExam("full")); +$("startFullExam2").addEventListener("click",()=>startExam("full")); +$("examExit").addEventListener("click",()=>{ if(confirm("Выйти из экзамена? Прогресс кейса не сохранится.")) closeExam(); }); +document.addEventListener("keydown",e=>{ if(e.key==="Escape"&&!exam.hidden){ if(confirm("Выйти из экзамена?")) closeExam(); } }); /* ---- старт ---- */ -renderCards(); - +refreshAll(); +if(!profile.name){ /* мягко: ничего не навязываем */ } diff --git a/index.html b/index.html index dd0d571..249c34c 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,8 @@ -Неотложка · Экзамен для студентов-медиков - +МедИгра · Неотложка — тренажёр для медиков + @@ -12,46 +12,50 @@ + +
+
+ 🩺 МедИграНеотложка +
+
+ + + +
+
+
+ +
- 🩺 Экзамен · 6 курс · по протоколам МЗ РК -

Говорящие пациенты.
Экзамен по неотложке.

-

Шесть клинических кейсов по алгоритмам клинических протоколов МЗ РК. Пациент сам рассказывает жалобы — включите звук. Вы сами назначаете обследования и лечение, ставите диагноз и оказываете экстренную помощь на время. В конце — оценка в процентах.

+ 🎮 Игровой тренажёр · 6 курс · по протоколам МЗ РК +

Стань профессором
неотложной помощи.

+

Говорящие пациенты, клинические кейсы и экстренная помощь на время. Решай, копи опыт, открывай достижения и поднимайся в рейтинге — от Стажёра до Профессора.

- К кейсам - -
-
-
6кейсов
-
4этапа в кейсе
-
60сна экстренную помощь
-
%итоговая оценка
+ +
+
- -
+ +
-

Как устроен экзамен

-

Каждый кейс — это 4 шага решения. За каждый шаг начисляются баллы. Чем точнее выбор, тем выше оценка.

-
-
1

Обследования

Выберите необходимые исследования из списка. Не назначайте лишнее.

-
2

Лечение

Назначьте препараты из перечня. Избегайте противопоказанных.

-
3

Диагноз

Выберите верный диагноз из вариантов.

-
4

Экстренная помощь ⏱

За 60 секунд выберите правильные действия. Время пошло!

-
+

Карта прохождения

+

Три темы неотложной патологии. Открывай кейсы, зарабатывай звёзды и опыт за каждую тему.

+
-

Клинические кейсы

-

Решите кейс отдельно или пройдите весь экзамен. Карточки открывают пошаговый режим с оценкой.

+

Кейсы

+

Решай кейсы по очереди или выбирай любой. За каждый — опыт, звёзды и достижения.

- + @@ -59,22 +63,41 @@
-

Готовы проверить себя целиком?

-

Полный экзамен — все 6 кейсов подряд с итоговой оценкой в процентах и разбивкой по темам.

+

Готов к большому испытанию?

+

Полный экзамен — все 6 кейсов подряд. Результат попадёт в рейтинг игроков.

- +
+ +
+
+

Достижения

+

Открывай бейджи за точность, скорость и мастерство.

+
+
+
+ + +
+
+

Рейтинг игроков

+

Топ результатов полного экзамена. Сохраняется в этом браузере.

+
+
+
+
-

Неотложка — учебный экзаменационный симулятор для студентов-медиков. Алгоритмы решения основаны на клинических протоколах диагностики и лечения МЗ РК. Материалы носят обучающий характер и не заменяют первоисточники.

+

МедИгра · Неотложка — игровой тренажёр для студентов-медиков. Алгоритмы основаны на клинических протоколах диагностики и лечения МЗ РК. Материалы носят обучающий характер.

+
- +